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:
claude-code-best
2026-04-09 17:40:50 +08:00
committed by GitHub
parent f17b7c7163
commit 2da6514095
81 changed files with 9875 additions and 40 deletions

View File

@@ -0,0 +1,162 @@
import { describe, test, expect, beforeEach, afterAll, mock, spyOn } from "bun:test";
// Mock config before importing modules that depend on it
const mockConfig = {
port: 3000,
host: "0.0.0.0",
apiKeys: ["test-key-1", "test-key-2"],
baseUrl: "",
pollTimeout: 8,
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
};
mock.module("../config", () => ({
config: mockConfig,
getBaseUrl: () => "http://localhost:3000",
}));
import { validateApiKey, hashApiKey } from "../auth/api-key";
import { generateWorkerJwt, verifyWorkerJwt } from "../auth/jwt";
import { issueToken, resolveToken } from "../auth/token";
import { storeReset, storeCreateUser } from "../store";
// ---------- api-key ----------
describe("validateApiKey", () => {
test("validates a configured API key", () => {
expect(validateApiKey("test-key-1")).toBe(true);
expect(validateApiKey("test-key-2")).toBe(true);
});
test("rejects unknown key", () => {
expect(validateApiKey("unknown-key")).toBe(false);
});
test("rejects undefined", () => {
expect(validateApiKey(undefined)).toBe(false);
});
test("rejects empty string", () => {
expect(validateApiKey("")).toBe(false);
});
});
describe("hashApiKey", () => {
test("produces consistent SHA-256 hex", () => {
const hash = hashApiKey("my-key");
expect(hash).toMatch(/^[0-9a-f]{64}$/);
expect(hashApiKey("my-key")).toBe(hash);
});
test("different keys produce different hashes", () => {
expect(hashApiKey("key-a")).not.toBe(hashApiKey("key-b"));
});
});
// ---------- jwt ----------
describe("JWT", () => {
// JWT reads process.env.RCS_API_KEYS directly (not via config)
const originalKeys = process.env.RCS_API_KEYS;
beforeEach(() => {
process.env.RCS_API_KEYS = "jwt-test-secret";
});
afterAll(() => {
process.env.RCS_API_KEYS = originalKeys;
});
describe("generateWorkerJwt", () => {
test("produces a three-part base64url token", () => {
const token = generateWorkerJwt("ses_123", 3600);
const parts = token.split(".");
expect(parts).toHaveLength(3);
for (const part of parts) {
expect(part).toMatch(/^[A-Za-z0-9_-]+$/);
}
});
test("contains correct header", () => {
const token = generateWorkerJwt("ses_123", 3600);
const header = JSON.parse(atob(token.split(".")[0].replace(/-/g, "+").replace(/_/g, "/")));
expect(header.alg).toBe("HS256");
expect(header.typ).toBe("JWT");
});
test("throws when no API key configured", () => {
delete process.env.RCS_API_KEYS;
expect(() => generateWorkerJwt("ses_123", 3600)).toThrow("No API key configured");
process.env.RCS_API_KEYS = "jwt-test-secret";
});
});
describe("verifyWorkerJwt", () => {
test("verifies a valid token", () => {
const token = generateWorkerJwt("ses_abc", 3600);
const payload = verifyWorkerJwt(token);
expect(payload).not.toBeNull();
expect(payload!.session_id).toBe("ses_abc");
expect(payload!.role).toBe("worker");
expect(payload!.iat).toBeGreaterThan(0);
expect(payload!.exp).toBeGreaterThan(payload!.iat);
});
test("returns null for expired token", () => {
const token = generateWorkerJwt("ses_old", -10);
expect(verifyWorkerJwt(token)).toBeNull();
});
test("returns null for malformed token (not 3 parts)", () => {
expect(verifyWorkerJwt("a.b")).toBeNull();
expect(verifyWorkerJwt("just-a-string")).toBeNull();
});
test("returns null for tampered signature", () => {
const token = generateWorkerJwt("ses_123", 3600);
const parts = token.split(".");
const tampered = `${parts[0]}.${parts[1]}.${parts[2].slice(0, -4)}xxxx`;
expect(verifyWorkerJwt(tampered)).toBeNull();
});
test("returns null for wrong signing key", () => {
const token = generateWorkerJwt("ses_123", 3600);
process.env.RCS_API_KEYS = "wrong-key";
expect(verifyWorkerJwt(token)).toBeNull();
process.env.RCS_API_KEYS = "jwt-test-secret";
});
});
});
// ---------- token ----------
describe("issueToken / resolveToken", () => {
beforeEach(() => {
storeReset();
});
test("issues and resolves a token", () => {
storeCreateUser("alice");
const { token, expires_in } = issueToken("alice");
expect(token).toMatch(/^rct_\d+_[0-9a-f]+$/);
expect(expires_in).toBe(86400);
expect(resolveToken(token)).toBe("alice");
});
test("returns null for unknown token", () => {
expect(resolveToken("nonexistent")).toBeNull();
});
test("returns null for undefined token", () => {
expect(resolveToken(undefined)).toBeNull();
});
test("tokens are unique", () => {
storeCreateUser("alice");
const t1 = issueToken("alice").token;
const t2 = issueToken("alice").token;
expect(t1).not.toBe(t2);
});
});

View File

@@ -0,0 +1,101 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
// Mock config with very short timeout for testing
const mockConfig = {
port: 3000,
host: "0.0.0.0",
apiKeys: ["test-api-key"],
baseUrl: "http://localhost:3000",
pollTimeout: 8,
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
};
mock.module("../config", () => ({
config: mockConfig,
getBaseUrl: () => "http://localhost:3000",
}));
import {
storeReset,
storeCreateEnvironment,
storeUpdateEnvironment,
storeCreateSession,
storeUpdateSession,
storeGetEnvironment,
storeGetSession,
storeListActiveEnvironments,
} from "../store";
describe("Disconnect Monitor Logic", () => {
beforeEach(() => {
storeReset();
});
// Test the logic directly rather than the interval-based monitor
// to avoid long-running tests with timers
test("environment times out when lastPollAt is too old", () => {
const env = storeCreateEnvironment({ secret: "s" });
const timeoutMs = 300 * 1000; // 5 minutes
// Simulate lastPollAt being 6 minutes ago
const oldDate = new Date(Date.now() - timeoutMs - 60000);
storeUpdateEnvironment(env.id, { lastPollAt: oldDate });
// Check the timeout logic (same as in disconnect-monitor.ts)
const now = Date.now();
const envs = storeListActiveEnvironments();
for (const e of envs) {
if (e.lastPollAt && now - e.lastPollAt.getTime() > timeoutMs) {
storeUpdateEnvironment(e.id, { status: "disconnected" });
}
}
const updated = storeGetEnvironment(env.id);
expect(updated?.status).toBe("disconnected");
});
test("environment stays active when lastPollAt is recent", () => {
const env = storeCreateEnvironment({ secret: "s" });
const timeoutMs = 300 * 1000;
// lastPollAt is recent (just created)
const now = Date.now();
const envs = storeListActiveEnvironments();
for (const e of envs) {
if (e.lastPollAt && now - e.lastPollAt.getTime() > timeoutMs) {
storeUpdateEnvironment(e.id, { status: "disconnected" });
}
}
const updated = storeGetEnvironment(env.id);
expect(updated?.status).toBe("active");
});
test("session becomes inactive when updatedAt is too old", () => {
const session = storeCreateSession({ status: "idle" });
storeUpdateSession(session.id, { status: "running" });
const timeoutMs = 300 * 1000 * 2; // 2x disconnect timeout
// Simulate updatedAt being older than 2x timeout
// We can't directly set updatedAt, but we can verify the logic
// by checking that recently updated sessions are not marked inactive
const now = Date.now();
const rec = storeGetSession(session.id);
// Session was just updated, should not be inactive
expect(rec?.status).toBe("running");
expect(now - rec!.updatedAt.getTime()).toBeLessThan(timeoutMs);
});
test("session stays running when recently updated", () => {
const session = storeCreateSession({});
storeUpdateSession(session.id, { status: "running" });
const timeoutMs = 300 * 1000 * 2;
const rec = storeGetSession(session.id);
expect(rec?.status).toBe("running");
expect(Date.now() - rec!.updatedAt.getTime()).toBeLessThan(timeoutMs);
});
});

View File

@@ -0,0 +1,176 @@
import { describe, test, expect, beforeEach } from "bun:test";
import { EventBus, getEventBus, removeEventBus, getAllEventBuses } from "../transport/event-bus";
describe("EventBus", () => {
let bus: EventBus;
beforeEach(() => {
bus = new EventBus();
});
describe("publish", () => {
test("publishes event with seqNum starting at 1", () => {
const event = bus.publish({
id: "e1",
sessionId: "s1",
type: "user",
payload: { content: "hello" },
direction: "outbound",
});
expect(event.seqNum).toBe(1);
expect(event.createdAt).toBeGreaterThan(0);
});
test("increments seqNum on each publish", () => {
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: {}, direction: "inbound" });
const event = bus.publish({ id: "e3", sessionId: "s1", type: "result", payload: {}, direction: "inbound" });
expect(event.seqNum).toBe(3);
});
test("throws when publishing to a closed bus", () => {
bus.close();
expect(() =>
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" }),
).toThrow("EventBus is closed");
});
});
describe("subscribe", () => {
test("receives published events", () => {
const received: unknown[] = [];
bus.subscribe((event) => received.push(event));
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: { content: "hi" }, direction: "outbound" });
expect(received).toHaveLength(1);
expect((received[0] as any).payload).toEqual({ content: "hi" });
});
test("unsubscribe stops receiving events", () => {
const received: unknown[] = [];
const unsub = bus.subscribe((event) => received.push(event));
unsub();
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
expect(received).toHaveLength(0);
});
test("multiple subscribers all receive events", () => {
const r1: unknown[] = [];
const r2: unknown[] = [];
bus.subscribe((e) => r1.push(e));
bus.subscribe((e) => r2.push(e));
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
expect(r1).toHaveLength(1);
expect(r2).toHaveLength(1);
});
test("subscriber error does not affect other subscribers", () => {
const received: unknown[] = [];
bus.subscribe(() => {
throw new Error("boom");
});
bus.subscribe((e) => received.push(e));
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
expect(received).toHaveLength(1);
});
test("subscriberCount", () => {
expect(bus.subscriberCount()).toBe(0);
const unsub1 = bus.subscribe(() => {});
expect(bus.subscriberCount()).toBe(1);
const unsub2 = bus.subscribe(() => {});
expect(bus.subscriberCount()).toBe(2);
unsub1();
expect(bus.subscriberCount()).toBe(1);
});
});
describe("getEventsSince", () => {
test("returns events after given seqNum", () => {
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: {}, direction: "inbound" });
bus.publish({ id: "e3", sessionId: "s1", type: "result", payload: {}, direction: "inbound" });
const events = bus.getEventsSince(1);
expect(events).toHaveLength(2);
expect(events[0].seqNum).toBe(2);
expect(events[1].seqNum).toBe(3);
});
test("returns empty for seqNum beyond last", () => {
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
expect(bus.getEventsSince(1)).toHaveLength(0);
});
test("returns all events when seqNum is 0", () => {
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: {}, direction: "inbound" });
expect(bus.getEventsSince(0)).toHaveLength(2);
});
});
describe("getLastSeqNum", () => {
test("returns 0 for empty bus", () => {
expect(bus.getLastSeqNum()).toBe(0);
});
test("returns last seqNum after publishes", () => {
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
bus.publish({ id: "e2", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
expect(bus.getLastSeqNum()).toBe(2);
});
});
describe("close", () => {
test("clears subscribers and prevents publishing", () => {
bus.subscribe(() => {});
bus.close();
expect(bus.subscriberCount()).toBe(0);
expect(() => bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" })).toThrow();
});
});
});
describe("EventBus registry", () => {
beforeEach(() => {
// Clean up global registry
for (const [key] of getAllEventBuses()) {
removeEventBus(key);
}
});
describe("getEventBus", () => {
test("creates new bus for unknown session", () => {
const bus = getEventBus("s1");
expect(bus).toBeInstanceOf(EventBus);
expect(getAllEventBuses().has("s1")).toBe(true);
});
test("returns same bus for same session", () => {
const bus1 = getEventBus("s1");
const bus2 = getEventBus("s1");
expect(bus1).toBe(bus2);
});
});
describe("removeEventBus", () => {
test("removes and closes bus", () => {
const bus = getEventBus("s2");
removeEventBus("s2");
expect(getAllEventBuses().has("s2")).toBe(false);
expect(() => bus.publish({ id: "e1", sessionId: "s2", type: "user", payload: {}, direction: "outbound" })).toThrow();
});
test("no-op for non-existent bus", () => {
expect(() => removeEventBus("nonexistent")).not.toThrow();
});
});
describe("getAllEventBuses", () => {
test("returns all registered buses", () => {
getEventBus("a");
getEventBus("b");
expect(getAllEventBuses().size).toBeGreaterThanOrEqual(2);
});
});
});

View File

@@ -0,0 +1,208 @@
import { describe, test, expect, beforeEach, afterAll, mock } from "bun:test";
// Mock config before imports
const mockConfig = {
port: 3000,
host: "0.0.0.0",
apiKeys: ["test-api-key"],
baseUrl: "http://localhost:3000",
pollTimeout: 8,
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
};
mock.module("../config", () => ({
config: mockConfig,
getBaseUrl: () => "http://localhost:3000",
}));
import { Hono } from "hono";
import { storeReset, storeCreateUser } from "../store";
import { apiKeyAuth, sessionIngressAuth, uuidAuth, getUuidFromRequest } from "../auth/middleware";
import { issueToken } from "../auth/token";
import { generateWorkerJwt } from "../auth/jwt";
// Helper: create a test app with middleware and a simple handler
function createTestApp() {
const app = new Hono();
// Test route for apiKeyAuth
app.get("/api-key-test", apiKeyAuth, (c) => {
return c.json({ username: c.get("username") || null });
});
// Test route for sessionIngressAuth
app.get("/ingress/:id", sessionIngressAuth, (c) => {
return c.json({ ok: true, jwtPayload: c.get("jwtPayload") || null });
});
// Test route for uuidAuth
app.get("/uuid-test", uuidAuth, (c) => {
return c.json({ uuid: c.get("uuid") });
});
// Test route for getUuidFromRequest
app.get("/uuid-extract", (c) => {
return c.json({ uuid: getUuidFromRequest(c) });
});
return app;
}
describe("Auth Middleware", () => {
let app: Hono;
beforeEach(() => {
storeReset();
app = createTestApp();
});
describe("apiKeyAuth", () => {
test("accepts valid API key with username header", async () => {
const res = await app.request("/api-key-test", {
headers: {
Authorization: "Bearer test-api-key",
"X-Username": "alice",
},
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.username).toBe("alice");
});
test("accepts valid API key with username query param", async () => {
const res = await app.request("/api-key-test?username=bob", {
headers: { Authorization: "Bearer test-api-key" },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.username).toBe("bob");
});
test("accepts valid session token", async () => {
storeCreateUser("charlie");
const { token } = issueToken("charlie");
const res = await app.request("/api-key-test", {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.username).toBe("charlie");
});
test("rejects invalid token", async () => {
const res = await app.request("/api-key-test", {
headers: { Authorization: "Bearer wrong-key" },
});
expect(res.status).toBe(401);
});
test("rejects missing token", async () => {
const res = await app.request("/api-key-test");
expect(res.status).toBe(401);
});
test("accepts token from query param", async () => {
storeCreateUser("dave");
const { token } = issueToken("dave");
const res = await app.request(`/api-key-test?token=${token}`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.username).toBe("dave");
});
});
describe("sessionIngressAuth", () => {
const originalKeys = process.env.RCS_API_KEYS;
beforeEach(() => {
process.env.RCS_API_KEYS = "test-api-key";
});
afterAll(() => {
process.env.RCS_API_KEYS = originalKeys;
});
test("accepts valid API key", async () => {
const res = await app.request("/ingress/ses_123", {
headers: { Authorization: "Bearer test-api-key" },
});
expect(res.status).toBe(200);
});
test("accepts valid JWT with matching session_id", async () => {
const jwt = generateWorkerJwt("ses_123", 3600);
const res = await app.request("/ingress/ses_123", {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.jwtPayload).not.toBeNull();
expect(body.jwtPayload.session_id).toBe("ses_123");
});
test("rejects JWT with mismatched session_id", async () => {
const jwt = generateWorkerJwt("ses_456", 3600);
const res = await app.request("/ingress/ses_123", {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(res.status).toBe(403);
});
test("rejects missing token", async () => {
const res = await app.request("/ingress/ses_123");
expect(res.status).toBe(401);
});
test("rejects invalid token", async () => {
const res = await app.request("/ingress/ses_123", {
headers: { Authorization: "Bearer invalid" },
});
expect(res.status).toBe(401);
});
});
describe("uuidAuth", () => {
test("accepts UUID from query param", async () => {
const res = await app.request("/uuid-test?uuid=test-uuid-1");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.uuid).toBe("test-uuid-1");
});
test("accepts UUID from header", async () => {
const res = await app.request("/uuid-test", {
headers: { "X-UUID": "test-uuid-2" },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.uuid).toBe("test-uuid-2");
});
test("rejects missing UUID", async () => {
const res = await app.request("/uuid-test");
expect(res.status).toBe(401);
});
});
describe("getUuidFromRequest", () => {
test("extracts from query param", async () => {
const res = await app.request("/uuid-extract?uuid=from-query");
const body = await res.json();
expect(body.uuid).toBe("from-query");
});
test("extracts from header", async () => {
const res = await app.request("/uuid-extract", {
headers: { "X-UUID": "from-header" },
});
const body = await res.json();
expect(body.uuid).toBe("from-header");
});
test("returns undefined when no UUID", async () => {
const res = await app.request("/uuid-extract");
const body = await res.json();
expect(body.uuid).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,906 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
// Mock config
const mockConfig = {
port: 3000,
host: "0.0.0.0",
apiKeys: ["test-api-key"],
baseUrl: "http://localhost:3000",
pollTimeout: 1,
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
};
mock.module("../config", () => ({
config: mockConfig,
getBaseUrl: () => "http://localhost:3000",
}));
import { Hono } from "hono";
import { storeReset, storeCreateSession, storeCreateEnvironment, storeBindSession } from "../store";
import { removeEventBus, getAllEventBuses } from "../transport/event-bus";
import { issueToken } from "../auth/token";
// Import route modules
import v1Sessions from "../routes/v1/sessions";
import v1Environments from "../routes/v1/environments";
import v1EnvironmentsWork from "../routes/v1/environments.work";
import v1SessionIngress from "../routes/v1/session-ingress";
import v2CodeSessions from "../routes/v2/code-sessions";
import v2Worker from "../routes/v2/worker";
import v2WorkerEvents from "../routes/v2/worker-events";
import webAuth from "../routes/web/auth";
import webSessions from "../routes/web/sessions";
import webControl from "../routes/web/control";
import webEnvironments from "../routes/web/environments";
function createApp() {
const app = new Hono();
app.route("/v1/sessions", v1Sessions);
app.route("/v1/environments", v1Environments);
app.route("/v1/environments", v1EnvironmentsWork);
app.route("/v2/session_ingress", v1SessionIngress);
app.route("/v1/code/sessions", v2CodeSessions);
app.route("/v1/code/sessions", v2Worker);
app.route("/v1/code/sessions", v2WorkerEvents);
app.route("/web", webAuth);
app.route("/web", webSessions);
app.route("/web", webControl);
app.route("/web", webEnvironments);
return app;
}
const AUTH_HEADERS = { Authorization: "Bearer test-api-key", "X-Username": "testuser" };
describe("V1 Session Routes", () => {
let app: Hono;
beforeEach(() => {
storeReset();
for (const [key] of getAllEventBuses()) {
removeEventBus(key);
}
app = createApp();
});
test("POST /v1/sessions — creates a session", async () => {
const res = await app.request("/v1/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ title: "Test Session" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toMatch(/^session_/);
expect(body.title).toBe("Test Session");
expect(body.status).toBe("idle");
});
test("POST /v1/sessions — requires auth", async () => {
const res = await app.request("/v1/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(401);
});
test("GET /v1/sessions/:id — returns created session", async () => {
const createRes = await app.request("/v1/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const getRes = await app.request(`/v1/sessions/${id}`, {
headers: AUTH_HEADERS,
});
expect(getRes.status).toBe(200);
const body = await getRes.json();
expect(body.id).toBe(id);
});
test("GET /v1/sessions/:id — 404 for unknown session", async () => {
const res = await app.request("/v1/sessions/nope", {
headers: AUTH_HEADERS,
});
expect(res.status).toBe(404);
});
test("PATCH /v1/sessions/:id — updates title", async () => {
const createRes = await app.request("/v1/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const patchRes = await app.request(`/v1/sessions/${id}`, {
method: "PATCH",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ title: "Updated Title" }),
});
expect(patchRes.status).toBe(200);
const body = await patchRes.json();
expect(body.title).toBe("Updated Title");
});
test("POST /v1/sessions/:id/archive — archives session", async () => {
const createRes = await app.request("/v1/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const archiveRes = await app.request(`/v1/sessions/${id}/archive`, {
method: "POST",
headers: AUTH_HEADERS,
});
expect(archiveRes.status).toBe(200);
});
test("POST /v1/sessions/:id/events — publishes events", async () => {
const createRes = await app.request("/v1/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const eventsRes = await app.request(`/v1/sessions/${id}/events`, {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ events: [{ type: "user", content: "hello" }] }),
});
expect(eventsRes.status).toBe(200);
const body = await eventsRes.json();
expect(body.events).toBe(1);
});
test("POST /v1/sessions with environment_id creates work item", async () => {
// First register an environment
const envRes = await app.request("/v1/environments/bridge", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ machine_name: "test" }),
});
const { environment_id } = await envRes.json();
const sessRes = await app.request("/v1/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ environment_id }),
});
expect(sessRes.status).toBe(200);
const body = await sessRes.json();
expect(body.environment_id).toBe(environment_id);
});
test("POST /v1/sessions with invalid environment_id — session created, work item fails silently", async () => {
const sessRes = await app.request("/v1/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ environment_id: "env_nonexistent" }),
});
expect(sessRes.status).toBe(200);
const body = await sessRes.json();
expect(body.id).toMatch(/^session_/);
});
test("POST /v1/sessions with events — publishes initial events", async () => {
const sessRes = await app.request("/v1/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ events: [{ type: "init", data: "starting" }] }),
});
expect(sessRes.status).toBe(200);
});
});
describe("V1 Environment Routes", () => {
let app: Hono;
beforeEach(() => {
storeReset();
app = createApp();
});
test("POST /v1/environments/bridge — registers environment", async () => {
const res = await app.request("/v1/environments/bridge", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ machine_name: "mac1", directory: "/home" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.environment_id).toMatch(/^env_/);
expect(body.status).toBe("active");
});
test("DELETE /v1/environments/bridge/:id — deregisters environment", async () => {
const envRes = await app.request("/v1/environments/bridge", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { environment_id } = await envRes.json();
const delRes = await app.request(`/v1/environments/bridge/${environment_id}`, {
method: "DELETE",
headers: AUTH_HEADERS,
});
expect(delRes.status).toBe(200);
});
test("POST /v1/environments/:id/bridge/reconnect — reconnects environment", async () => {
const envRes = await app.request("/v1/environments/bridge", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { environment_id } = await envRes.json();
const reconnectRes = await app.request(`/v1/environments/${environment_id}/bridge/reconnect`, {
method: "POST",
headers: AUTH_HEADERS,
});
expect(reconnectRes.status).toBe(200);
});
});
describe("V1 Work Routes", () => {
let app: Hono;
let envId: string;
beforeEach(async () => {
storeReset();
app = createApp();
const envRes = await app.request("/v1/environments/bridge", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
envId = (await envRes.json()).environment_id;
});
test("GET /v1/environments/:id/work/poll — returns 204 when no work", async () => {
const res = await app.request(`/v1/environments/${envId}/work/poll`, {
headers: AUTH_HEADERS,
});
expect(res.status).toBe(204);
});
test("work lifecycle: create → poll → ack → stop", async () => {
// Create session with environment (creates work item)
const sessRes = await app.request("/v1/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ environment_id: envId }),
});
const sessionId = (await sessRes.json()).id;
// Poll for work
const pollRes = await app.request(`/v1/environments/${envId}/work/poll`, {
headers: AUTH_HEADERS,
});
expect(pollRes.status).toBe(200);
const work = await pollRes.json();
expect(work.id).toMatch(/^work_/);
expect(work.data.id).toBe(sessionId);
// Ack work
const ackRes = await app.request(`/v1/environments/${envId}/work/${work.id}/ack`, {
method: "POST",
headers: AUTH_HEADERS,
});
expect(ackRes.status).toBe(200);
// Stop work
const stopRes = await app.request(`/v1/environments/${envId}/work/${work.id}/stop`, {
method: "POST",
headers: AUTH_HEADERS,
});
expect(stopRes.status).toBe(200);
});
test("POST work heartbeat", async () => {
// Create session + work
await app.request("/v1/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ environment_id: envId }),
});
const pollRes = await app.request(`/v1/environments/${envId}/work/poll`, {
headers: AUTH_HEADERS,
});
const work = await pollRes.json();
const hbRes = await app.request(`/v1/environments/${envId}/work/${work.id}/heartbeat`, {
method: "POST",
headers: AUTH_HEADERS,
});
expect(hbRes.status).toBe(200);
const body = await hbRes.json();
expect(body.lease_extended).toBe(true);
});
});
describe("V2 Code Session Routes", () => {
let app: Hono;
beforeEach(() => {
storeReset();
process.env.RCS_API_KEYS = "test-api-key";
app = createApp();
});
test("POST /v1/code/sessions — creates code session", async () => {
const res = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ title: "Code Session" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.session.id).toMatch(/^cse_/);
expect(body.session.title).toBe("Code Session");
});
test("POST /v1/code/sessions/:id/bridge — returns bridge info with JWT", async () => {
// Create code session
const createRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = (await createRes.json()).session;
const bridgeRes = await app.request(`/v1/code/sessions/${id}/bridge`, {
method: "POST",
headers: AUTH_HEADERS,
});
expect(bridgeRes.status).toBe(200);
const body = await bridgeRes.json();
expect(body.api_base_url).toBe("http://localhost:3000");
expect(body.worker_epoch).toBe(1);
expect(body.worker_jwt).toBeTruthy();
expect(body.expires_in).toBe(3600);
});
test("POST /v1/code/sessions/:id/bridge — 404 for unknown session", async () => {
const res = await app.request("/v1/code/sessions/nope/bridge", {
method: "POST",
headers: AUTH_HEADERS,
});
expect(res.status).toBe(404);
});
});
describe("V2 Worker Routes", () => {
let app: Hono;
beforeEach(() => {
storeReset();
process.env.RCS_API_KEYS = "test-api-key";
app = createApp();
});
test("POST /v1/code/sessions/:id/worker/register — increments epoch", async () => {
// Create session
const createRes = await app.request("/v1/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const regRes = await app.request(`/v1/code/sessions/${id}/worker/register`, {
method: "POST",
headers: AUTH_HEADERS,
});
expect(regRes.status).toBe(200);
const body = await regRes.json();
expect(body.worker_epoch).toBe(1);
});
test("POST /v1/code/sessions/:id/worker/register — 404 for unknown", async () => {
const res = await app.request("/v1/code/sessions/nope/worker/register", {
method: "POST",
headers: AUTH_HEADERS,
});
expect(res.status).toBe(404);
});
});
describe("Web Auth Routes", () => {
let app: Hono;
beforeEach(() => {
storeReset();
app = createApp();
});
test("POST /web/bind — binds session to UUID", async () => {
// Create session first
const sessRes = await app.request("/v1/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await sessRes.json();
const bindRes = await app.request("/web/bind?uuid=test-uuid", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: id }),
});
expect(bindRes.status).toBe(200);
const body = await bindRes.json();
expect(body.ok).toBe(true);
});
test("POST /web/bind — 404 for unknown session", async () => {
const res = await app.request("/web/bind?uuid=test-uuid", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: "nope" }),
});
expect(res.status).toBe(404);
});
test("POST /web/bind — 400 when missing params", async () => {
const res = await app.request("/web/bind", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
});
describe("Web Session Routes", () => {
let app: Hono;
beforeEach(() => {
storeReset();
for (const [key] of getAllEventBuses()) {
removeEventBus(key);
}
app = createApp();
});
test("POST /web/sessions — creates and auto-binds session", async () => {
const res = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Web Session" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toMatch(/^session_/);
expect(body.source).toBe("web");
});
test("GET /web/sessions — returns sessions owned by UUID", async () => {
// Create and bind
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const listRes = await app.request("/web/sessions?uuid=user-1");
expect(listRes.status).toBe(200);
const sessions = await listRes.json();
expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe(id);
});
test("GET /web/sessions — requires UUID", async () => {
const res = await app.request("/web/sessions");
expect(res.status).toBe(401);
});
test("GET /web/sessions/all — lists only sessions owned by requesting UUID", async () => {
// Create 2 sessions via different users
await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
await app.request("/web/sessions?uuid=user-2", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const allRes = await app.request("/web/sessions/all?uuid=user-1");
expect(allRes.status).toBe(200);
const sessions = await allRes.json();
expect(sessions).toHaveLength(1); // only user-1's session, not user-2's
});
test("GET /web/sessions/:id — returns owned session", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const getRes = await app.request(`/web/sessions/${id}?uuid=user-1`);
expect(getRes.status).toBe(200);
});
test("GET /web/sessions/:id — 403 for non-owner", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const getRes = await app.request(`/web/sessions/${id}?uuid=user-2`);
expect(getRes.status).toBe(403);
});
test("GET /web/sessions/:id/history — returns events", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`);
expect(histRes.status).toBe(200);
const body = await histRes.json();
expect(body.events).toEqual([]);
});
test("GET /web/sessions/:id/history — 403 for non-owner", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-2`);
expect(histRes.status).toBe(403);
});
test("GET /web/sessions/:id — 404 after session deleted", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
// Archive/delete the session via v1
await app.request(`/v1/sessions/${id}/archive`, {
method: "POST",
headers: AUTH_HEADERS,
});
// Session still exists (archived), so we can still get it
const getRes = await app.request(`/web/sessions/${id}?uuid=user-1`);
// After archive, session status is "archived" but still exists
expect(getRes.status).toBe(200);
});
test("GET /web/sessions/:id/history — 404 for non-existent session", async () => {
// Bind to a non-existent session won't work, but if ownership was set
// and session deleted, we need to test the 404 path
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
// Delete the session from store directly
const { storeDeleteSession } = await import("../store");
storeDeleteSession(id);
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`);
expect(histRes.status).toBe(404);
});
test("POST /web/sessions with invalid environment_id — handles work item error", async () => {
const res = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ environment_id: "env_nonexistent" }),
});
// Session is still created even if work item fails
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toMatch(/^session_/);
});
test("GET /web/sessions/:id/events — returns SSE stream", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const eventsRes = await app.request(`/web/sessions/${id}/events?uuid=user-1`);
expect(eventsRes.status).toBe(200);
expect(eventsRes.headers.get("Content-Type")).toBe("text/event-stream");
// Read initial keepalive and cancel
const reader = eventsRes.body?.getReader();
if (reader) {
const { value } = await reader.read();
const text = new TextDecoder().decode(value!);
expect(text).toContain(": keepalive");
reader.cancel();
}
});
test("GET /web/sessions/:id/events — 403 for non-owner", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const eventsRes = await app.request(`/web/sessions/${id}/events?uuid=user-2`);
expect(eventsRes.status).toBe(403);
});
});
describe("Web Control Routes", () => {
let app: Hono;
let sessionId: string;
beforeEach(async () => {
storeReset();
for (const [key] of getAllEventBuses()) {
removeEventBus(key);
}
app = createApp();
// Create and bind session
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
sessionId = (await createRes.json()).id;
});
test("POST /web/sessions/:id/events — sends user message", async () => {
const res = await app.request(`/web/sessions/${sessionId}/events?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "user", content: "hello" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("ok");
expect(body.event).toBeTruthy();
});
test("POST /web/sessions/:id/events — 403 for non-owner", async () => {
const res = await app.request(`/web/sessions/${sessionId}/events?uuid=user-2`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "user", content: "hello" }),
});
expect(res.status).toBe(403);
});
test("POST /web/sessions/:id/control — sends control request", async () => {
const res = await app.request(`/web/sessions/${sessionId}/control?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "permission_response", approved: true, request_id: "r1" }),
});
expect(res.status).toBe(200);
});
test("POST /web/sessions/:id/interrupt — interrupts session", async () => {
const res = await app.request(`/web/sessions/${sessionId}/interrupt?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
expect(res.status).toBe(200);
});
test("POST /web/sessions/:id/interrupt — 403 for non-owner", async () => {
const res = await app.request(`/web/sessions/${sessionId}/interrupt?uuid=user-2`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
expect(res.status).toBe(403);
});
test("POST /web/sessions/:id/control — 403 for non-owner", async () => {
const res = await app.request(`/web/sessions/${sessionId}/control?uuid=user-2`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "permission_response", approved: true }),
});
expect(res.status).toBe(403);
});
test("POST /web/sessions/:id/events — 403 for non-existent session with no ownership", async () => {
const res = await app.request("/web/sessions/nonexistent/events?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "user", content: "hello" }),
});
expect(res.status).toBe(403);
});
});
describe("Web Environment Routes", () => {
let app: Hono;
beforeEach(() => {
storeReset();
app = createApp();
});
test("GET /web/environments — lists active environments", async () => {
// Register an env via v1
await app.request("/v1/environments/bridge", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ machine_name: "mac1" }),
});
const res = await app.request("/web/environments?uuid=user-1");
expect(res.status).toBe(200);
const envs = await res.json();
expect(envs).toHaveLength(1);
expect(envs[0].machine_name).toBe("mac1");
});
test("GET /web/environments — requires UUID", async () => {
const res = await app.request("/web/environments");
expect(res.status).toBe(401);
});
});
describe("V1 Session Ingress Routes (HTTP)", () => {
let app: Hono;
beforeEach(() => {
storeReset();
for (const [key] of getAllEventBuses()) {
removeEventBus(key);
}
process.env.RCS_API_KEYS = "test-api-key";
app = createApp();
});
test("POST /v2/session_ingress/session/:sessionId/events — ingests events with API key", async () => {
// Create session first
const sessRes = await app.request("/v1/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await sessRes.json();
const res = await app.request(`/v2/session_ingress/session/${id}/events`, {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ events: [{ type: "assistant", content: "response" }] }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("ok");
});
test("POST /v2/session_ingress/session/:sessionId/events — rejects without auth", async () => {
const res = await app.request("/v2/session_ingress/session/nope/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ events: [] }),
});
expect(res.status).toBe(401);
});
test("POST /v2/session_ingress/session/:sessionId/events — 404 for unknown session", async () => {
const res = await app.request("/v2/session_ingress/session/nope/events", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ events: [{ type: "user", content: "hi" }] }),
});
expect(res.status).toBe(404);
});
});
describe("V2 Worker Events Routes", () => {
let app: Hono;
beforeEach(() => {
storeReset();
for (const [key] of getAllEventBuses()) {
removeEventBus(key);
}
process.env.RCS_API_KEYS = "test-api-key";
app = createApp();
});
test("POST /v1/code/sessions/:id/worker/events — publishes worker events", async () => {
// Create session
const sessRes = await app.request("/v1/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await sessRes.json();
const res = await app.request(`/v1/code/sessions/${id}/worker/events`, {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify([{ type: "assistant", content: "response" }]),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("ok");
expect(body.count).toBe(1);
});
test("PUT /v1/code/sessions/:id/worker/state — updates session status", async () => {
const sessRes = await app.request("/v1/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await sessRes.json();
const res = await app.request(`/v1/code/sessions/${id}/worker/state`, {
method: "PUT",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ status: "running" }),
});
expect(res.status).toBe(200);
});
test("PUT /v1/code/sessions/:id/worker/external_metadata — no-op", async () => {
const sessRes = await app.request("/v1/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await sessRes.json();
const res = await app.request(`/v1/code/sessions/${id}/worker/external_metadata`, {
method: "PUT",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ meta: "data" }),
});
expect(res.status).toBe(200);
});
test("POST /v1/code/sessions/:id/worker/events/:eventId/delivery — no-op", async () => {
const sessRes = await app.request("/v1/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await sessRes.json();
const res = await app.request(`/v1/code/sessions/${id}/worker/events/evt123/delivery`, {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ status: "received" }),
});
expect(res.status).toBe(200);
});
});

View File

@@ -0,0 +1,386 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
// Mock config before importing modules
const mockConfig = {
port: 3000,
host: "0.0.0.0",
apiKeys: ["test-api-key"],
baseUrl: "http://localhost:3000",
pollTimeout: 8,
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
};
mock.module("../config", () => ({
config: mockConfig,
getBaseUrl: () => "http://localhost:3000",
}));
import { storeReset, storeCreateEnvironment } from "../store";
import {
createSession,
createCodeSession,
getSession,
updateSessionTitle,
updateSessionStatus,
archiveSession,
incrementEpoch,
listSessions,
listSessionSummaries,
listSessionSummariesByUsername,
listSessionsByEnvironment,
} from "../services/session";
import {
registerEnvironment,
deregisterEnvironment,
getEnvironment,
updatePollTime,
listActiveEnvironments,
listActiveEnvironmentsResponse,
listActiveEnvironmentsByUsername,
reconnectEnvironment,
} from "../services/environment";
import { normalizePayload, publishSessionEvent } from "../services/transport";
import { getEventBus, removeEventBus, getAllEventBuses } from "../transport/event-bus";
// ---------- Session Service ----------
describe("Session Service", () => {
beforeEach(() => {
storeReset();
for (const [key] of getAllEventBuses()) {
removeEventBus(key);
}
});
describe("createSession", () => {
test("creates a session with defaults", () => {
const resp = createSession({});
expect(resp.id).toMatch(/^session_/);
expect(resp.status).toBe("idle");
expect(resp.source).toBe("remote-control");
expect(resp.environment_id).toBeNull();
expect(resp.worker_epoch).toBe(0);
expect(resp.created_at).toBeGreaterThan(0);
});
test("creates a session with all options", () => {
const env = storeCreateEnvironment({ secret: "s" });
const resp = createSession({
environment_id: env.id,
title: "My Session",
source: "cli",
permission_mode: "auto",
});
expect(resp.environment_id).toBe(env.id);
expect(resp.title).toBe("My Session");
expect(resp.source).toBe("cli");
expect(resp.permission_mode).toBe("auto");
});
test("creates session with username", () => {
const resp = createSession({ username: "alice" });
expect(resp.username).toBe("alice");
});
});
describe("createCodeSession", () => {
test("creates a code session with cse_ prefix", () => {
const resp = createCodeSession({});
expect(resp.id).toMatch(/^cse_/);
});
});
describe("getSession", () => {
test("returns null for non-existent session", () => {
expect(getSession("nope")).toBeNull();
});
test("returns created session", () => {
const created = createSession({});
const fetched = getSession(created.id);
expect(fetched).not.toBeNull();
expect(fetched!.id).toBe(created.id);
});
});
describe("updateSessionTitle", () => {
test("updates title", () => {
const s = createSession({});
updateSessionTitle(s.id, "New Title");
expect(getSession(s.id)?.title).toBe("New Title");
});
});
describe("updateSessionStatus", () => {
test("updates status", () => {
const s = createSession({});
updateSessionStatus(s.id, "active");
expect(getSession(s.id)?.status).toBe("active");
});
});
describe("archiveSession", () => {
test("sets status to archived and removes event bus", () => {
const s = createSession({});
// Create event bus for this session
getEventBus(s.id);
archiveSession(s.id);
expect(getSession(s.id)?.status).toBe("archived");
expect(getAllEventBuses().has(s.id)).toBe(false);
});
});
describe("incrementEpoch", () => {
test("increments epoch by 1", () => {
const s = createSession({});
expect(incrementEpoch(s.id)).toBe(1);
expect(incrementEpoch(s.id)).toBe(2);
expect(getSession(s.id)?.worker_epoch).toBe(2);
});
test("throws for non-existent session", () => {
expect(() => incrementEpoch("nope")).toThrow("Session not found");
});
});
describe("listSessions", () => {
test("returns all sessions", () => {
createSession({});
createSession({});
expect(listSessions()).toHaveLength(2);
});
});
describe("listSessionSummaries", () => {
test("returns summaries with correct fields", () => {
createSession({ title: "Test" });
const summaries = listSessionSummaries();
expect(summaries).toHaveLength(1);
expect(summaries[0].title).toBe("Test");
expect(summaries[0].updated_at).toBeGreaterThan(0);
// Summary should not have environment_id
expect("environment_id" in summaries[0]).toBe(false);
});
});
describe("listSessionSummariesByUsername", () => {
test("filters by username", () => {
createSession({ username: "alice" });
createSession({ username: "bob" });
expect(listSessionSummariesByUsername("alice")).toHaveLength(1);
});
});
describe("listSessionsByEnvironment", () => {
test("filters by environment", () => {
const env = storeCreateEnvironment({ secret: "s" });
createSession({ environment_id: env.id });
createSession({});
expect(listSessionsByEnvironment(env.id)).toHaveLength(1);
});
});
});
// ---------- Environment Service ----------
describe("Environment Service", () => {
beforeEach(() => {
storeReset();
});
describe("registerEnvironment", () => {
test("registers environment with defaults", () => {
const result = registerEnvironment({});
expect(result.environment_id).toMatch(/^env_/);
expect(result.environment_secret).toBe("test-api-key");
expect(result.status).toBe("active");
});
test("registers with options", () => {
const result = registerEnvironment({
machine_name: "mac1",
directory: "/home/user",
branch: "main",
git_repo_url: "https://github.com/test/repo",
max_sessions: 5,
worker_type: "custom",
});
const env = getEnvironment(result.environment_id);
expect(env?.machineName).toBe("mac1");
expect(env?.directory).toBe("/home/user");
expect(env?.maxSessions).toBe(5);
});
test("registers with username", () => {
const result = registerEnvironment({ username: "alice" });
const env = getEnvironment(result.environment_id);
expect(env?.username).toBe("alice");
});
});
describe("deregisterEnvironment", () => {
test("sets status to deregistered", () => {
const result = registerEnvironment({});
deregisterEnvironment(result.environment_id);
const env = getEnvironment(result.environment_id);
expect(env?.status).toBe("deregistered");
});
});
describe("updatePollTime", () => {
test("updates lastPollAt", () => {
const result = registerEnvironment({});
const before = getEnvironment(result.environment_id)?.lastPollAt;
// Small delay to ensure time difference
updatePollTime(result.environment_id);
const after = getEnvironment(result.environment_id)?.lastPollAt;
expect(after!.getTime()).toBeGreaterThanOrEqual(before!.getTime());
});
});
describe("listActiveEnvironments", () => {
test("returns active environments", () => {
registerEnvironment({});
registerEnvironment({});
expect(listActiveEnvironments()).toHaveLength(2);
});
});
describe("listActiveEnvironmentsResponse", () => {
test("returns response format", () => {
registerEnvironment({ machine_name: "mac1" });
const envs = listActiveEnvironmentsResponse();
expect(envs).toHaveLength(1);
expect(envs[0].machine_name).toBe("mac1");
expect(envs[0].last_poll_at).toBeGreaterThan(0);
});
});
describe("listActiveEnvironmentsByUsername", () => {
test("filters by username", () => {
registerEnvironment({ username: "alice" });
registerEnvironment({ username: "bob" });
expect(listActiveEnvironmentsByUsername("alice")).toHaveLength(1);
});
});
describe("reconnectEnvironment", () => {
test("sets status back to active", () => {
const result = registerEnvironment({});
deregisterEnvironment(result.environment_id);
expect(getEnvironment(result.environment_id)?.status).toBe("deregistered");
reconnectEnvironment(result.environment_id);
expect(getEnvironment(result.environment_id)?.status).toBe("active");
});
});
});
// ---------- Transport Service ----------
describe("Transport Service", () => {
beforeEach(() => {
storeReset();
for (const [key] of getAllEventBuses()) {
removeEventBus(key);
}
});
describe("normalizePayload", () => {
test("handles string payload", () => {
const result = normalizePayload("user", "hello world");
expect(result.content).toBe("hello world");
expect(result.raw).toBe("hello world");
});
test("handles null payload", () => {
const result = normalizePayload("user", null);
expect(result.content).toBe("");
expect(result.raw).toBeNull();
});
test("handles object with direct content", () => {
const result = normalizePayload("user", { content: "direct text" });
expect(result.content).toBe("direct text");
});
test("handles object with message.content string", () => {
const result = normalizePayload("assistant", { message: { role: "assistant", content: "reply" } });
expect(result.content).toBe("reply");
});
test("handles object with message.content array", () => {
const result = normalizePayload("assistant", {
message: {
content: [
{ type: "text", text: "hello " },
{ type: "text", text: "world" },
],
},
});
expect(result.content).toBe("hello world");
});
test("preserves tool fields", () => {
const result = normalizePayload("tool_use", { tool_name: "Bash", tool_input: { cmd: "ls" } });
expect(result.tool_name).toBe("Bash");
expect(result.tool_input).toEqual({ cmd: "ls" });
});
test("preserves permission fields", () => {
const result = normalizePayload("permission", {
request_id: "req_1",
approved: true,
updated_input: { cmd: "ls -la" },
});
expect(result.request_id).toBe("req_1");
expect(result.approved).toBe(true);
expect(result.updated_input).toEqual({ cmd: "ls -la" });
});
test("preserves message field", () => {
const msg = { role: "user", content: "hi" };
const result = normalizePayload("user", { message: msg });
expect(result.message).toEqual(msg);
});
test("uses name as tool_name fallback", () => {
const result = normalizePayload("tool", { name: "Read" });
expect(result.tool_name).toBe("Read");
});
test("uses input as tool_input fallback", () => {
const result = normalizePayload("tool", { input: { path: "/tmp" } });
expect(result.tool_input).toEqual({ path: "/tmp" });
});
test("handles empty content array", () => {
const result = normalizePayload("assistant", {
message: { content: [] },
});
expect(result.content).toBe("");
});
test("handles undefined payload", () => {
const result = normalizePayload("user", undefined);
expect(result.content).toBe("");
});
});
describe("publishSessionEvent", () => {
test("publishes event to session bus", () => {
const event = publishSessionEvent("s1", "user", { content: "hello" }, "outbound");
expect(event.type).toBe("user");
expect(event.direction).toBe("outbound");
expect(event.sessionId).toBe("s1");
expect(event.seqNum).toBe(1);
});
test("normalizes payload before publishing", () => {
const event = publishSessionEvent("s1", "assistant", { message: { content: "reply" } }, "inbound");
const payload = event.payload as Record<string, unknown>;
expect(payload.content).toBe("reply");
});
});
});

View File

@@ -0,0 +1,179 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
// Mock config
const mockConfig = {
port: 3000,
host: "0.0.0.0",
apiKeys: ["test-api-key"],
baseUrl: "http://localhost:3000",
pollTimeout: 8,
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
};
mock.module("../config", () => ({
config: mockConfig,
getBaseUrl: () => "http://localhost:3000",
}));
import { Hono } from "hono";
import { storeReset } from "../store";
import { removeEventBus, getAllEventBuses, getEventBus } from "../transport/event-bus";
import { createSSEWriter, createSSEStream } from "../transport/sse-writer";
/** Read up to N bytes from a Response stream, then cancel */
async function readPartialStream(res: Response, maxBytes = 4096): Promise<string> {
const reader = res.body?.getReader();
if (!reader) return "";
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
while (totalBytes < maxBytes) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
totalBytes += value.length;
// Cancel after we have some data (first keepalive + any initial events)
if (totalBytes > 0) break;
}
} finally {
reader.cancel();
}
const combined = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
return new TextDecoder().decode(combined);
}
describe("SSE Writer", () => {
describe("createSSEWriter", () => {
test("creates SSEWriter with send and close methods", () => {
const app = new Hono();
let capturedWriter: ReturnType<typeof createSSEWriter> | null = null;
app.get("/test", (c) => {
capturedWriter = createSSEWriter(c);
return c.text("ok");
});
app.request("/test");
expect(capturedWriter).not.toBeNull();
expect(typeof capturedWriter!.send).toBe("function");
expect(typeof capturedWriter!.close).toBe("function");
});
});
describe("createSSEStream", () => {
beforeEach(() => {
storeReset();
for (const [key] of getAllEventBuses()) {
removeEventBus(key);
}
});
test("returns Response with correct SSE headers", async () => {
const app = new Hono();
app.get("/stream/:sessionId", (c) => {
const sessionId = c.req.param("sessionId");
return createSSEStream(c, sessionId, 0);
});
const res = await app.request("/stream/s1");
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
expect(res.headers.get("Cache-Control")).toBe("no-cache");
expect(res.headers.get("Connection")).toBe("keep-alive");
expect(res.headers.get("X-Accel-Buffering")).toBe("no");
// Cancel the stream
res.body?.cancel();
});
test("sends initial keepalive", async () => {
const app = new Hono();
app.get("/stream/:sessionId", (c) => {
const sessionId = c.req.param("sessionId");
return createSSEStream(c, sessionId, 0);
});
const res = await app.request("/stream/s2");
const text = await readPartialStream(res);
expect(text).toContain(": keepalive");
});
test("sends historical events when fromSeqNum > 0", async () => {
// Pre-populate event bus with events
const bus = getEventBus("s3");
bus.publish({ id: "e1", sessionId: "s3", type: "user", payload: { content: "hello" }, direction: "outbound" });
bus.publish({ id: "e2", sessionId: "s3", type: "assistant", payload: { content: "hi" }, direction: "inbound" });
const app = new Hono();
app.get("/stream/:sessionId", (c) => {
const sessionId = c.req.param("sessionId");
const fromSeq = parseInt(c.req.query("fromSeq") || "0");
return createSSEStream(c, sessionId, fromSeq);
});
const res = await app.request("/stream/s3?fromSeq=1");
const text = await readPartialStream(res);
// Should replay events since seq 1 (i.e., event 2)
expect(text).toContain('"seqNum":2');
expect(text).toContain("assistant");
});
test("no historical events when fromSeqNum is 0", async () => {
const bus = getEventBus("s5");
bus.publish({ id: "e1", sessionId: "s5", type: "user", payload: {}, direction: "outbound" });
const app = new Hono();
app.get("/stream/:sessionId", (c) => {
const sessionId = c.req.param("sessionId");
return createSSEStream(c, sessionId, 0);
});
const res = await app.request("/stream/s5");
const text = await readPartialStream(res);
// With fromSeqNum=0, no historical replay, just keepalive
expect(text).toContain(": keepalive");
// Should NOT contain event data (only keepalive)
expect(text).not.toContain("event: message");
});
test("subscribes to new events and delivers them", async () => {
const app = new Hono();
app.get("/stream/:sessionId", (c) => {
const sessionId = c.req.param("sessionId");
return createSSEStream(c, sessionId, 0);
});
const res = await app.request("/stream/s6");
// Read initial keepalive first
const reader = res.body!.getReader();
const { value: firstChunk } = await reader.read();
const initialText = new TextDecoder().decode(firstChunk!);
expect(initialText).toContain(": keepalive");
// Now publish an event
const bus = getEventBus("s6");
bus.publish({ id: "e1", sessionId: "s6", type: "user", payload: { content: "real-time" }, direction: "outbound" });
// Read the event
const { value: secondChunk } = await reader.read();
const eventText = new TextDecoder().decode(secondChunk!);
expect(eventText).toContain("event: message");
expect(eventText).toContain("real-time");
reader.cancel();
});
});
});

View File

@@ -0,0 +1,396 @@
import { describe, test, expect, beforeEach } from "bun:test";
import {
storeReset,
storeCreateUser,
storeGetUser,
storeCreateToken,
storeGetUserByToken,
storeDeleteToken,
storeCreateEnvironment,
storeGetEnvironment,
storeUpdateEnvironment,
storeListActiveEnvironments,
storeListActiveEnvironmentsByUsername,
storeCreateSession,
storeGetSession,
storeUpdateSession,
storeListSessions,
storeListSessionsByUsername,
storeListSessionsByEnvironment,
storeDeleteSession,
storeBindSession,
storeIsSessionOwner,
storeListSessionsByOwnerUuid,
storeCreateWorkItem,
storeGetWorkItem,
storeGetPendingWorkItem,
storeUpdateWorkItem,
} from "../store";
describe("store", () => {
beforeEach(() => {
storeReset();
});
// ---------- User ----------
describe("storeCreateUser", () => {
test("creates a new user", () => {
const user = storeCreateUser("alice");
expect(user.username).toBe("alice");
expect(user.createdAt).toBeInstanceOf(Date);
});
test("returns existing user on duplicate create", () => {
const first = storeCreateUser("bob");
const second = storeCreateUser("bob");
expect(first).toBe(second);
});
});
describe("storeGetUser", () => {
test("returns undefined for non-existent user", () => {
expect(storeGetUser("nobody")).toBeUndefined();
});
test("returns created user", () => {
storeCreateUser("charlie");
const user = storeGetUser("charlie");
expect(user?.username).toBe("charlie");
});
});
// ---------- Token ----------
describe("storeCreateToken / storeGetUserByToken", () => {
test("creates and resolves token", () => {
storeCreateUser("dave");
storeCreateToken("dave", "tk_123");
const entry = storeGetUserByToken("tk_123");
expect(entry?.username).toBe("dave");
expect(entry?.createdAt).toBeInstanceOf(Date);
});
test("returns undefined for unknown token", () => {
expect(storeGetUserByToken("nonexistent")).toBeUndefined();
});
});
describe("storeDeleteToken", () => {
test("deletes an existing token", () => {
storeCreateUser("eve");
storeCreateToken("eve", "tk_del");
expect(storeDeleteToken("tk_del")).toBe(true);
expect(storeGetUserByToken("tk_del")).toBeUndefined();
});
test("returns false for non-existent token", () => {
expect(storeDeleteToken("nope")).toBe(false);
});
});
// ---------- Environment ----------
describe("storeCreateEnvironment", () => {
test("creates environment with defaults", () => {
const env = storeCreateEnvironment({ secret: "s1" });
expect(env.id).toMatch(/^env_/);
expect(env.secret).toBe("s1");
expect(env.status).toBe("active");
expect(env.machineName).toBeNull();
expect(env.maxSessions).toBe(1);
expect(env.workerType).toBe("claude_code");
expect(env.lastPollAt).toBeInstanceOf(Date);
});
test("creates environment with all options", () => {
const env = storeCreateEnvironment({
secret: "s2",
machineName: "mac1",
directory: "/home/user",
branch: "main",
gitRepoUrl: "https://github.com/test/repo",
maxSessions: 5,
workerType: "custom",
bridgeId: "bridge1",
username: "alice",
});
expect(env.machineName).toBe("mac1");
expect(env.directory).toBe("/home/user");
expect(env.branch).toBe("main");
expect(env.gitRepoUrl).toBe("https://github.com/test/repo");
expect(env.maxSessions).toBe(5);
expect(env.workerType).toBe("custom");
expect(env.bridgeId).toBe("bridge1");
expect(env.username).toBe("alice");
});
});
describe("storeGetEnvironment", () => {
test("returns undefined for non-existent env", () => {
expect(storeGetEnvironment("env_no")).toBeUndefined();
});
test("returns created environment", () => {
const env = storeCreateEnvironment({ secret: "s" });
expect(storeGetEnvironment(env.id)).toBe(env);
});
});
describe("storeUpdateEnvironment", () => {
test("updates existing environment", () => {
const env = storeCreateEnvironment({ secret: "s" });
const result = storeUpdateEnvironment(env.id, { status: "disconnected" });
expect(result).toBe(true);
const updated = storeGetEnvironment(env.id);
expect(updated?.status).toBe("disconnected");
expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual(env.updatedAt.getTime());
});
test("returns false for non-existent environment", () => {
expect(storeUpdateEnvironment("env_no", { status: "active" })).toBe(false);
});
});
describe("storeListActiveEnvironments", () => {
test("returns only active environments", () => {
const env1 = storeCreateEnvironment({ secret: "s1" });
const env2 = storeCreateEnvironment({ secret: "s2" });
storeUpdateEnvironment(env1.id, { status: "deregistered" });
const active = storeListActiveEnvironments();
expect(active).toHaveLength(1);
expect(active[0].id).toBe(env2.id);
});
});
describe("storeListActiveEnvironmentsByUsername", () => {
test("filters by username", () => {
storeCreateEnvironment({ secret: "s1", username: "alice" });
storeCreateEnvironment({ secret: "s2", username: "bob" });
const aliceEnvs = storeListActiveEnvironmentsByUsername("alice");
expect(aliceEnvs).toHaveLength(1);
expect(aliceEnvs[0].username).toBe("alice");
});
});
// ---------- Session ----------
describe("storeCreateSession", () => {
test("creates session with defaults", () => {
const session = storeCreateSession({});
expect(session.id).toMatch(/^session_/);
expect(session.status).toBe("idle");
expect(session.source).toBe("remote-control");
expect(session.environmentId).toBeNull();
expect(session.workerEpoch).toBe(0);
});
test("creates session with options", () => {
const env = storeCreateEnvironment({ secret: "s" });
const session = storeCreateSession({
environmentId: env.id,
title: "Test Session",
source: "cli",
permissionMode: "auto",
username: "alice",
});
expect(session.environmentId).toBe(env.id);
expect(session.title).toBe("Test Session");
expect(session.source).toBe("cli");
expect(session.permissionMode).toBe("auto");
expect(session.username).toBe("alice");
});
test("creates session with custom idPrefix", () => {
const session = storeCreateSession({ idPrefix: "cse_" });
expect(session.id).toMatch(/^cse_/);
});
});
describe("storeGetSession", () => {
test("returns undefined for non-existent session", () => {
expect(storeGetSession("nope")).toBeUndefined();
});
});
describe("storeUpdateSession", () => {
test("updates existing session", () => {
const session = storeCreateSession({});
const result = storeUpdateSession(session.id, { title: "Updated", status: "active" });
expect(result).toBe(true);
const updated = storeGetSession(session.id);
expect(updated?.title).toBe("Updated");
expect(updated?.status).toBe("active");
});
test("returns false for non-existent session", () => {
expect(storeUpdateSession("nope", { title: "x" })).toBe(false);
});
test("increments workerEpoch", () => {
const session = storeCreateSession({});
storeUpdateSession(session.id, { workerEpoch: 1 });
expect(storeGetSession(session.id)?.workerEpoch).toBe(1);
});
});
describe("storeListSessions", () => {
test("returns all sessions", () => {
storeCreateSession({});
storeCreateSession({});
expect(storeListSessions()).toHaveLength(2);
});
});
describe("storeListSessionsByUsername", () => {
test("filters by username", () => {
storeCreateSession({ username: "alice" });
storeCreateSession({ username: "bob" });
expect(storeListSessionsByUsername("alice")).toHaveLength(1);
});
});
describe("storeListSessionsByEnvironment", () => {
test("filters by environment", () => {
const env = storeCreateEnvironment({ secret: "s" });
storeCreateSession({ environmentId: env.id });
storeCreateSession({});
expect(storeListSessionsByEnvironment(env.id)).toHaveLength(1);
});
});
describe("storeDeleteSession", () => {
test("deletes existing session", () => {
const session = storeCreateSession({});
expect(storeDeleteSession(session.id)).toBe(true);
expect(storeGetSession(session.id)).toBeUndefined();
});
test("returns false for non-existent session", () => {
expect(storeDeleteSession("nope")).toBe(false);
});
});
// ---------- Session Ownership ----------
describe("storeBindSession / storeIsSessionOwner", () => {
test("binds and checks ownership", () => {
const session = storeCreateSession({});
storeBindSession(session.id, "uuid-1");
expect(storeIsSessionOwner(session.id, "uuid-1")).toBe(true);
expect(storeIsSessionOwner(session.id, "uuid-2")).toBe(false);
});
test("unbound session has no owner", () => {
const session = storeCreateSession({});
expect(storeIsSessionOwner(session.id, "uuid-1")).toBe(false);
});
test("multiple owners per session", () => {
const session = storeCreateSession({});
storeBindSession(session.id, "uuid-1");
storeBindSession(session.id, "uuid-2");
expect(storeIsSessionOwner(session.id, "uuid-1")).toBe(true);
expect(storeIsSessionOwner(session.id, "uuid-2")).toBe(true);
});
});
describe("storeListSessionsByOwnerUuid", () => {
test("returns sessions owned by uuid", () => {
const s1 = storeCreateSession({});
const s2 = storeCreateSession({});
storeBindSession(s1.id, "uuid-1");
storeBindSession(s2.id, "uuid-1");
const owned = storeListSessionsByOwnerUuid("uuid-1");
expect(owned).toHaveLength(2);
});
test("returns empty for unknown uuid", () => {
expect(storeListSessionsByOwnerUuid("nope")).toHaveLength(0);
});
test("excludes deleted sessions", () => {
const s1 = storeCreateSession({});
storeBindSession(s1.id, "uuid-1");
storeDeleteSession(s1.id);
expect(storeListSessionsByOwnerUuid("uuid-1")).toHaveLength(0);
});
});
// ---------- Work Items ----------
describe("storeCreateWorkItem", () => {
test("creates work item with defaults", () => {
const item = storeCreateWorkItem({
environmentId: "env1",
sessionId: "ses1",
secret: "sec1",
});
expect(item.id).toMatch(/^work_/);
expect(item.environmentId).toBe("env1");
expect(item.sessionId).toBe("ses1");
expect(item.state).toBe("pending");
expect(item.secret).toBe("sec1");
});
});
describe("storeGetWorkItem", () => {
test("returns undefined for non-existent", () => {
expect(storeGetWorkItem("nope")).toBeUndefined();
});
test("returns created work item", () => {
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
expect(storeGetWorkItem(item.id)).toBe(item);
});
});
describe("storeGetPendingWorkItem", () => {
test("returns pending work for environment", () => {
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
const found = storeGetPendingWorkItem("env1");
expect(found?.id).toBe(item.id);
});
test("returns undefined when no pending work", () => {
storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
expect(storeGetPendingWorkItem("env2")).toBeUndefined();
});
test("skips non-pending items", () => {
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
storeUpdateWorkItem(item.id, { state: "dispatched" });
expect(storeGetPendingWorkItem("env1")).toBeUndefined();
});
});
describe("storeUpdateWorkItem", () => {
test("updates existing work item", () => {
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
expect(storeUpdateWorkItem(item.id, { state: "acked" })).toBe(true);
expect(storeGetWorkItem(item.id)?.state).toBe("acked");
});
test("returns false for non-existent", () => {
expect(storeUpdateWorkItem("nope", { state: "acked" })).toBe(false);
});
});
// ---------- storeReset ----------
describe("storeReset", () => {
test("clears all data", () => {
storeCreateUser("alice");
storeCreateEnvironment({ secret: "s" });
storeCreateSession({});
storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
storeReset();
expect(storeGetUser("alice")).toBeUndefined();
expect(storeListActiveEnvironments()).toHaveLength(0);
expect(storeListSessions()).toHaveLength(0);
expect(storeGetPendingWorkItem("env1")).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,156 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
// Mock config before imports
const mockConfig = {
port: 3000,
host: "0.0.0.0",
apiKeys: ["test-api-key"],
baseUrl: "http://localhost:3000",
pollTimeout: 1, // Short timeout for tests
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
};
mock.module("../config", () => ({
config: mockConfig,
getBaseUrl: () => "http://localhost:3000",
}));
import { storeReset, storeCreateEnvironment, storeCreateSession, storeGetWorkItem, storeGetPendingWorkItem } from "../store";
import {
createWorkItem,
pollWork,
ackWork,
stopWork,
heartbeatWork,
reconnectWorkForEnvironment,
} from "../services/work-dispatch";
describe("Work Dispatch", () => {
let envId: string;
let sessionId: string;
beforeEach(() => {
storeReset();
const env = storeCreateEnvironment({ secret: "s" });
envId = env.id;
const session = storeCreateSession({ environmentId: envId });
sessionId = session.id;
});
describe("createWorkItem", () => {
test("creates work item for active environment", async () => {
const workId = await createWorkItem(envId, sessionId);
expect(workId).toMatch(/^work_/);
const item = storeGetWorkItem(workId);
expect(item?.state).toBe("pending");
expect(item?.sessionId).toBe(sessionId);
});
test("throws for non-existent environment", async () => {
await expect(createWorkItem("env_no", sessionId)).rejects.toThrow("not found");
});
test("throws for inactive environment", async () => {
const inactiveEnv = storeCreateEnvironment({ secret: "s2" });
// Manually set status to deregistered
const { storeUpdateEnvironment } = await import("../store");
storeUpdateEnvironment(inactiveEnv.id, { status: "deregistered" });
await expect(createWorkItem(inactiveEnv.id, sessionId)).rejects.toThrow("not active");
});
test("encodes work secret as base64 JSON", async () => {
const workId = await createWorkItem(envId, sessionId);
const item = storeGetWorkItem(workId);
const decoded = JSON.parse(Buffer.from(item!.secret, "base64url").toString());
expect(decoded.version).toBe(1);
expect(decoded.session_ingress_token).toBe("test-api-key");
expect(decoded.api_base_url).toBe("http://localhost:3000");
});
});
describe("pollWork", () => {
test("returns null when no work available (timeout)", async () => {
const result = await pollWork(envId, 0.1);
expect(result).toBeNull();
});
test("returns pending work and marks as dispatched", async () => {
const workId = await createWorkItem(envId, sessionId);
const result = await pollWork(envId, 1);
expect(result).not.toBeNull();
expect(result!.id).toBe(workId);
expect(result!.state).toBe("dispatched");
expect(result!.data.type).toBe("session");
expect(result!.data.id).toBe(sessionId);
// Work should no longer be pending
expect(storeGetPendingWorkItem(envId)).toBeUndefined();
});
test("does not return work for different environment", async () => {
const env2 = storeCreateEnvironment({ secret: "s2" });
await createWorkItem(envId, sessionId);
const result = await pollWork(env2.id, 0.1);
expect(result).toBeNull();
});
});
describe("ackWork", () => {
test("marks work as acked", async () => {
const workId = await createWorkItem(envId, sessionId);
ackWork(workId);
expect(storeGetWorkItem(workId)?.state).toBe("acked");
});
});
describe("stopWork", () => {
test("marks work as completed", async () => {
const workId = await createWorkItem(envId, sessionId);
stopWork(workId);
expect(storeGetWorkItem(workId)?.state).toBe("completed");
});
});
describe("heartbeatWork", () => {
test("extends lease and returns heartbeat info", async () => {
const workId = await createWorkItem(envId, sessionId);
const result = heartbeatWork(workId);
expect(result.lease_extended).toBe(true);
expect(result.ttl_seconds).toBe(40); // heartbeatInterval * 2
expect(result.last_heartbeat).toBeTruthy();
});
test("returns default state for non-existent work", async () => {
const result = heartbeatWork("work_no");
expect(result.state).toBe("acked");
});
});
describe("reconnectWorkForEnvironment", () => {
test("creates work items for idle sessions in environment", async () => {
// Create another idle session
storeCreateSession({ environmentId: envId });
const workIds = await reconnectWorkForEnvironment(envId);
expect(workIds).toHaveLength(2);
for (const id of workIds) {
expect(storeGetWorkItem(id)?.state).toBe("pending");
}
});
test("skips non-idle sessions", async () => {
const activeSession = storeCreateSession({ environmentId: envId });
const { storeUpdateSession } = await import("../store");
storeUpdateSession(activeSession.id, { status: "active" });
const workIds = await reconnectWorkForEnvironment(envId);
// Only the original idle session should get work
expect(workIds).toHaveLength(1);
});
test("returns empty for environment with no sessions", async () => {
const emptyEnv = storeCreateEnvironment({ secret: "s_empty" });
const workIds = await reconnectWorkForEnvironment(emptyEnv.id);
expect(workIds).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,484 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
// Mock config before imports
const mockConfig = {
port: 3000,
host: "0.0.0.0",
apiKeys: ["test-api-key"],
baseUrl: "http://localhost:3000",
pollTimeout: 8,
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
};
mock.module("../config", () => ({
config: mockConfig,
getBaseUrl: () => "http://localhost:3000",
}));
import { storeReset } from "../store";
import { getEventBus, removeEventBus, getAllEventBuses } from "../transport/event-bus";
import {
ingestBridgeMessage,
handleWebSocketOpen,
handleWebSocketMessage,
handleWebSocketClose,
closeAllConnections,
} from "../transport/ws-handler";
// Minimal WSContext mock
function createMockWs(readyState = 1) {
const sent: string[] = [];
return {
readyState,
send: (data: string) => sent.push(data),
close: (_code?: number, _reason?: string) => {},
getSentData: () => sent,
} as any;
}
describe("ws-handler", () => {
beforeEach(() => {
storeReset();
for (const [key] of getAllEventBuses()) {
removeEventBus(key);
}
closeAllConnections();
});
describe("ingestBridgeMessage", () => {
test("ignores keep_alive messages", () => {
const bus = getEventBus("s1");
const events: unknown[] = [];
bus.subscribe((e) => events.push(e));
ingestBridgeMessage("s1", { type: "keep_alive" });
expect(events).toHaveLength(0);
});
test("derives type from message.role for user messages", () => {
const bus = getEventBus("s1");
const events: unknown[] = [];
bus.subscribe((e) => events.push(e));
ingestBridgeMessage("s1", {
message: { role: "user", content: "hello" },
uuid: "u1",
});
expect(events).toHaveLength(1);
expect((events[0] as any).type).toBe("user");
expect((events[0] as any).direction).toBe("inbound");
});
test("derives type from message.role for assistant messages", () => {
const bus = getEventBus("s1");
const events: unknown[] = [];
bus.subscribe((e) => events.push(e));
ingestBridgeMessage("s1", {
message: { role: "assistant", content: [{ type: "text", text: "response" }] },
uuid: "u2",
});
expect(events).toHaveLength(1);
expect((events[0] as any).type).toBe("assistant");
const payload = (events[0] as any).payload as Record<string, unknown>;
expect(payload.content).toBe("response");
});
test("derives type from explicit type field", () => {
const bus = getEventBus("s1");
const events: unknown[] = [];
bus.subscribe((e) => events.push(e));
ingestBridgeMessage("s1", { type: "control_request", request_id: "r1", request: { subtype: "interrupt" } });
expect(events).toHaveLength(1);
expect((events[0] as any).type).toBe("control_request");
});
test("derives result type from subtype/result fields", () => {
const bus = getEventBus("s1");
const events: unknown[] = [];
bus.subscribe((e) => events.push(e));
ingestBridgeMessage("s1", { subtype: "success", uuid: "u3", result: "done" });
expect(events).toHaveLength(1);
expect((events[0] as any).type).toBe("result");
});
test("derives system type from session_id field", () => {
const bus = getEventBus("s1");
const events: unknown[] = [];
bus.subscribe((e) => events.push(e));
ingestBridgeMessage("s1", { session_id: "s1", init: true });
expect(events).toHaveLength(1);
expect((events[0] as any).type).toBe("system");
});
test("handles control_response type", () => {
const bus = getEventBus("s1");
const events: unknown[] = [];
bus.subscribe((e) => events.push(e));
ingestBridgeMessage("s1", {
type: "control_response",
response: { subtype: "success" },
});
expect(events).toHaveLength(1);
expect((events[0] as any).type).toBe("control_response");
});
test("handles partial_assistant type", () => {
const bus = getEventBus("s1");
const events: unknown[] = [];
bus.subscribe((e) => events.push(e));
ingestBridgeMessage("s1", {
type: "partial_assistant",
message: { content: "partial..." },
uuid: "u4",
});
expect(events).toHaveLength(1);
expect((events[0] as any).type).toBe("partial_assistant");
});
test("falls back to unknown type", () => {
const bus = getEventBus("s1");
const events: unknown[] = [];
bus.subscribe((e) => events.push(e));
ingestBridgeMessage("s1", { data: "something" });
expect(events).toHaveLength(1);
expect((events[0] as any).type).toBe("unknown");
});
});
describe("handleWebSocketOpen", () => {
test("subscribes to event bus and replays missed events", () => {
// Publish some events before WS connects
const bus = getEventBus("s1");
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: { content: "hello" }, direction: "outbound" });
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: { content: "hi" }, direction: "inbound" });
const ws = createMockWs();
handleWebSocketOpen(ws, "s1");
// Should have replayed the outbound event (only outbound events are forwarded to WS)
const sent = ws.getSentData();
expect(sent.length).toBeGreaterThanOrEqual(1);
// First message should be the outbound user event
const msg = JSON.parse(sent[0]);
expect(msg.type).toBe("user");
});
test("replaces existing connection for same session", () => {
const ws1 = createMockWs();
const ws2 = createMockWs();
handleWebSocketOpen(ws1, "s2");
handleWebSocketOpen(ws2, "s2");
// ws2 should be the active connection
const bus = getEventBus("s2");
bus.publish({ id: "e1", sessionId: "s2", type: "user", payload: { content: "test" }, direction: "outbound" });
expect(ws2.getSentData().length).toBeGreaterThanOrEqual(1);
});
});
describe("handleWebSocketMessage", () => {
test("parses NDJSON and ingests each message", () => {
const bus = getEventBus("s1");
const events: unknown[] = [];
bus.subscribe((e) => events.push(e));
const ws = createMockWs();
const data = JSON.stringify({ type: "user", message: { role: "user", content: "hello" } }) + "\n" +
JSON.stringify({ type: "assistant", message: { role: "assistant", content: "hi" } }) + "\n";
handleWebSocketMessage(ws, "s1", data);
expect(events).toHaveLength(2);
});
test("ignores malformed JSON lines", () => {
const bus = getEventBus("s1");
const events: unknown[] = [];
bus.subscribe((e) => events.push(e));
const ws = createMockWs();
handleWebSocketMessage(ws, "s1", "not json\n");
expect(events).toHaveLength(0);
});
});
describe("handleWebSocketClose", () => {
test("cleans up on close", () => {
const ws = createMockWs();
handleWebSocketOpen(ws, "s3");
handleWebSocketClose(ws, "s3", 1000, "done");
// After close, publishing events should not cause errors
const bus = getEventBus("s3");
expect(() =>
bus.publish({ id: "e1", sessionId: "s3", type: "user", payload: {}, direction: "outbound" })
).not.toThrow();
});
});
describe("toSDKMessage (via handleWebSocketOpen outbound delivery)", () => {
test("converts permission_response with approved=true", () => {
const bus = getEventBus("pr1");
const ws = createMockWs();
handleWebSocketOpen(ws, "pr1");
bus.publish({
id: "e1",
sessionId: "pr1",
type: "permission_response",
payload: { approved: true, request_id: "req1" },
direction: "outbound",
});
const sent = ws.getSentData();
const lastMsg = JSON.parse(sent[sent.length - 1]);
expect(lastMsg.type).toBe("control_response");
expect(lastMsg.response.subtype).toBe("success");
expect(lastMsg.response.request_id).toBe("req1");
expect(lastMsg.response.response.behavior).toBe("allow");
});
test("converts permission_response with approved=false", () => {
const bus = getEventBus("pr2");
const ws = createMockWs();
handleWebSocketOpen(ws, "pr2");
bus.publish({
id: "e2",
sessionId: "pr2",
type: "permission_response",
payload: { approved: false, request_id: "req2" },
direction: "outbound",
});
const sent = ws.getSentData();
const lastMsg = JSON.parse(sent[sent.length - 1]);
expect(lastMsg.type).toBe("control_response");
expect(lastMsg.response.subtype).toBe("error");
expect(lastMsg.response.error).toBe("Permission denied by user");
expect(lastMsg.response.response.behavior).toBe("deny");
});
test("converts permission_response with existing response object", () => {
const bus = getEventBus("pr3");
const ws = createMockWs();
handleWebSocketOpen(ws, "pr3");
bus.publish({
id: "e3",
sessionId: "pr3",
type: "control_response",
payload: { response: { subtype: "success", data: "custom" } },
direction: "outbound",
});
const sent = ws.getSentData();
const lastMsg = JSON.parse(sent[sent.length - 1]);
expect(lastMsg.type).toBe("control_response");
expect(lastMsg.response.subtype).toBe("success");
expect(lastMsg.response.data).toBe("custom");
});
test("converts interrupt event", () => {
const bus = getEventBus("int1");
const ws = createMockWs();
handleWebSocketOpen(ws, "int1");
bus.publish({
id: "e4",
sessionId: "int1",
type: "interrupt",
payload: { action: "interrupt" },
direction: "outbound",
});
const sent = ws.getSentData();
const lastMsg = JSON.parse(sent[sent.length - 1]);
expect(lastMsg.type).toBe("control_request");
expect(lastMsg.request_id).toBe("e4");
expect(lastMsg.request.subtype).toBe("interrupt");
});
test("converts control_request event", () => {
const bus = getEventBus("cr1");
const ws = createMockWs();
handleWebSocketOpen(ws, "cr1");
bus.publish({
id: "e5",
sessionId: "cr1",
type: "control_request",
payload: { request_id: "req5", request: { subtype: "permission", tool_name: "Bash" } },
direction: "outbound",
});
const sent = ws.getSentData();
const lastMsg = JSON.parse(sent[sent.length - 1]);
expect(lastMsg.type).toBe("control_request");
expect(lastMsg.request_id).toBe("req5");
expect(lastMsg.request.subtype).toBe("permission");
});
test("converts user_message event type", () => {
const bus = getEventBus("um1");
const ws = createMockWs();
handleWebSocketOpen(ws, "um1");
bus.publish({
id: "e6",
sessionId: "um1",
type: "user_message",
payload: { content: "hello world" },
direction: "outbound",
});
const sent = ws.getSentData();
const lastMsg = JSON.parse(sent[sent.length - 1]);
expect(lastMsg.type).toBe("user");
expect(lastMsg.message.content).toBe("hello world");
});
test("converts generic event type", () => {
const bus = getEventBus("gen1");
const ws = createMockWs();
handleWebSocketOpen(ws, "gen1");
bus.publish({
id: "e7",
sessionId: "gen1",
type: "status",
payload: { state: "running" },
direction: "outbound",
});
const sent = ws.getSentData();
const lastMsg = JSON.parse(sent[sent.length - 1]);
expect(lastMsg.type).toBe("status");
expect(lastMsg.message).toEqual({ state: "running" });
});
test("permission_response with updated_input", () => {
const bus = getEventBus("ui1");
const ws = createMockWs();
handleWebSocketOpen(ws, "ui1");
bus.publish({
id: "e8",
sessionId: "ui1",
type: "permission_response",
payload: { approved: true, request_id: "req8", updated_input: { cmd: "ls -la" } },
direction: "outbound",
});
const sent = ws.getSentData();
const lastMsg = JSON.parse(sent[sent.length - 1]);
expect(lastMsg.response.response.behavior).toBe("allow");
expect(lastMsg.response.response.updatedInput).toEqual({ cmd: "ls -la" });
});
test("permission_response with updated_permissions", () => {
const bus = getEventBus("up1");
const ws = createMockWs();
handleWebSocketOpen(ws, "up1");
const permissions = [{ type: "setMode", mode: "acceptEdits", destination: "session" }];
bus.publish({
id: "ep1",
sessionId: "up1",
type: "permission_response",
payload: {
approved: true,
request_id: "req-ep1",
updated_input: { plan: "my plan" },
updated_permissions: permissions,
},
direction: "outbound",
});
const sent = ws.getSentData();
const lastMsg = JSON.parse(sent[sent.length - 1]);
expect(lastMsg.type).toBe("control_response");
expect(lastMsg.response.subtype).toBe("success");
expect(lastMsg.response.response.behavior).toBe("allow");
expect(lastMsg.response.response.updatedInput).toEqual({ plan: "my plan" });
expect(lastMsg.response.response.updatedPermissions).toEqual(permissions);
});
test("permission_response denied with feedback message", () => {
const bus = getEventBus("dm1");
const ws = createMockWs();
handleWebSocketOpen(ws, "dm1");
bus.publish({
id: "dm1",
sessionId: "dm1",
type: "permission_response",
payload: {
approved: false,
request_id: "req-dm1",
message: "Please add more tests",
},
direction: "outbound",
});
const sent = ws.getSentData();
const lastMsg = JSON.parse(sent[sent.length - 1]);
expect(lastMsg.type).toBe("control_response");
expect(lastMsg.response.subtype).toBe("error");
expect(lastMsg.response.response.behavior).toBe("deny");
expect(lastMsg.response.message).toBe("Please add more tests");
});
test("does not forward inbound events to WS", () => {
const bus = getEventBus("no_in");
const ws = createMockWs();
handleWebSocketOpen(ws, "no_in");
bus.publish({
id: "e9",
sessionId: "no_in",
type: "assistant",
payload: { content: "reply" },
direction: "inbound",
});
// Only replayed events, no new inbound delivery
const sent = ws.getSentData();
// No outbound events were published, so only replay (if any)
// Since the bus was fresh, no replay
expect(sent).toHaveLength(0);
});
test("control_request falls back to payload when no request field", () => {
const bus = getEventBus("cf1");
const ws = createMockWs();
handleWebSocketOpen(ws, "cf1");
bus.publish({
id: "e10",
sessionId: "cf1",
type: "control_request",
payload: { request_id: "req10", subtype: "custom", data: "test" },
direction: "outbound",
});
const sent = ws.getSentData();
const lastMsg = JSON.parse(sent[sent.length - 1]);
expect(lastMsg.type).toBe("control_request");
expect(lastMsg.request_id).toBe("req10");
});
});
describe("closeAllConnections", () => {
test("closes all active connections", () => {
const ws1 = createMockWs();
const ws2 = createMockWs();
handleWebSocketOpen(ws1, "s1");
handleWebSocketOpen(ws2, "s2");
closeAllConnections();
// No errors thrown
});
test("no-op when no connections", () => {
expect(() => closeAllConnections()).not.toThrow();
});
});
});

View File

@@ -0,0 +1,12 @@
import { createHash } from "node:crypto";
import { config } from "../config";
/** Validate a raw API key token string */
export function validateApiKey(token: string | undefined): boolean {
if (!token) return false;
return config.apiKeys.includes(token);
}
export function hashApiKey(key: string): string {
return createHash("sha256").update(key).digest("hex");
}

View File

@@ -0,0 +1,92 @@
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Lightweight JWT implementation using HMAC-SHA256.
* No external dependencies — uses Node.js crypto.
*
* Token format: base64url(header).base64url(payload).base64url(signature)
* Used for V2 worker authentication (session ingress / SSE / CCR).
*/
interface JwtPayload {
session_id: string;
role: string;
iat: number;
exp: number;
}
function base64url(data: string | Buffer): string {
return Buffer.from(data as unknown as ArrayLike<number>)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function base64urlDecode(str: string): string {
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
return Buffer.from(padded, "base64").toString("utf-8");
}
function getSigningKey(): string {
const key = process.env.RCS_API_KEYS?.split(",").filter(Boolean)[0];
if (!key) throw new Error("No API key configured for JWT signing");
return key;
}
/** Generate a JWT for worker authentication. */
export function generateWorkerJwt(
sessionId: string,
expiresInSeconds: number,
): string {
const header = { alg: "HS256", typ: "JWT" };
const payload: JwtPayload = {
session_id: sessionId,
role: "worker",
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + expiresInSeconds,
};
const headerB64 = base64url(JSON.stringify(header));
const payloadB64 = base64url(JSON.stringify(payload));
const signingInput = `${headerB64}.${payloadB64}`;
const signature = createHmac("sha256", getSigningKey())
.update(signingInput)
.digest();
return `${signingInput}.${base64url(signature)}`;
}
/**
* Verify a JWT and return its payload, or null if invalid/expired.
* Uses timing-safe comparison to prevent timing attacks.
*/
export function verifyWorkerJwt(token: string): JwtPayload | null {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts;
// Verify signature
const signingInput = `${headerB64}.${payloadB64}`;
const expectedSig = createHmac("sha256", getSigningKey())
.update(signingInput)
.digest();
const actualSig = Buffer.from(
signatureB64.replace(/-/g, "+").replace(/_/g, "/"),
"base64",
);
if (expectedSig.length !== actualSig.length) return null;
if (!timingSafeEqual(expectedSig, actualSig)) return null;
// Decode payload
try {
const payload: JwtPayload = JSON.parse(base64urlDecode(payloadB64));
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
return payload;
} catch {
return null;
}
}

View File

@@ -0,0 +1,102 @@
import type { Context, Next } from "hono";
import { validateApiKey } from "./api-key";
import { verifyWorkerJwt } from "./jwt";
import { resolveToken } from "./token";
/** Extract Bearer token from Authorization header or ?token= query param */
function extractBearerToken(c: Context): string | undefined {
const authHeader = c.req.header("Authorization");
const queryToken = c.req.query("token");
return authHeader?.replace("Bearer ", "") || queryToken;
}
/**
* Unified authentication middleware — supports two modes:
*
* 1. **Token mode** (Web UI): Bearer token resolved via server-side lookup → username injected
* 2. **API Key mode** (CLI bridge): Valid API key + X-Username header → username injected
*/
export async function apiKeyAuth(c: Context, next: Next) {
const token = extractBearerToken(c);
// Try token authentication (Web UI)
const tokenUsername = resolveToken(token);
if (tokenUsername) {
c.set("username", tokenUsername);
await next();
return;
}
// Try API Key authentication (CLI bridge)
if (validateApiKey(token)) {
// Extract username from X-Username header or ?username= query param
const username = c.req.header("X-Username") || c.req.query("username");
if (username) {
c.set("username", username);
}
await next();
return;
}
return c.json({ error: { type: "unauthorized", message: "Invalid or missing auth token" } }, 401);
}
/**
* Session ingress authentication — accepts both API key and worker JWT.
*
* Used for SSE stream, CCR worker events, and WebSocket ingress endpoints.
* On JWT validation, stores the decoded payload in c.set("jwtPayload") for
* downstream handlers to inspect session_id if needed.
*/
export async function sessionIngressAuth(c: Context, next: Next) {
const token = extractBearerToken(c);
if (!token) {
return c.json({ error: { type: "unauthorized", message: "Missing auth token" } }, 401);
}
// Try API key first (backward compatible)
if (validateApiKey(token)) {
await next();
return;
}
// Try JWT verification — validate session_id matches route param
const payload = verifyWorkerJwt(token);
if (payload) {
const routeSessionId = c.req.param("id") || c.req.param("sessionId");
if (routeSessionId && payload.session_id !== routeSessionId) {
return c.json({ error: { type: "forbidden", message: "JWT session_id does not match target session" } }, 403);
}
c.set("jwtPayload", payload);
await next();
return;
}
return c.json({ error: { type: "unauthorized", message: "Invalid API key or JWT" } }, 401);
}
/** Accept CLI headers but don't validate them */
export async function acceptCliHeaders(c: Context, next: Next) {
await next();
}
/**
* Extract UUID from request — query param ?uuid= or header X-UUID
*/
export function getUuidFromRequest(c: Context): string | undefined {
return c.req.query("uuid") || c.req.header("X-UUID");
}
/**
* 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").
*/
export async function uuidAuth(c: Context, next: Next) {
const uuid = getUuidFromRequest(c);
if (!uuid) {
return c.json({ error: { type: "unauthorized", message: "Missing UUID" } }, 401);
}
c.set("uuid", uuid);
await next();
}

View File

@@ -0,0 +1,24 @@
import { storeCreateToken, storeGetUserByToken } from "../store";
let tokenCounter = 0;
/** Generate a random session token and associate it with a user */
export function issueToken(username: string): { token: string; expires_in: number } {
// Use crypto.getRandomValues for uniqueness
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const token = `rct_${tokenCounter++}_${hex}`;
storeCreateToken(username, token);
return { token, expires_in: 86400 };
}
/** Resolve a token to a username. Returns null if invalid. */
export function resolveToken(token: string | undefined): string | null {
if (!token) return null;
const entry = storeGetUserByToken(token);
if (!entry) return null;
return entry.username;
}

View File

@@ -0,0 +1,16 @@
export const config = {
version: process.env.RCS_VERSION || "0.1.0",
port: parseInt(process.env.RCS_PORT || "3000"),
host: process.env.RCS_HOST || "0.0.0.0",
apiKeys: (process.env.RCS_API_KEYS || "").split(",").filter(Boolean),
baseUrl: process.env.RCS_BASE_URL || "",
pollTimeout: parseInt(process.env.RCS_POLL_TIMEOUT || "8"),
heartbeatInterval: parseInt(process.env.RCS_HEARTBEAT_INTERVAL || "20"),
jwtExpiresIn: parseInt(process.env.RCS_JWT_EXPIRES_IN || "3600"),
disconnectTimeout: parseInt(process.env.RCS_DISCONNECT_TIMEOUT || "300"),
} as const;
export function getBaseUrl(): string {
if (config.baseUrl) return config.baseUrl;
return `http://localhost:${config.port}`;
}

View File

@@ -0,0 +1,103 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { serveStatic } from "hono/bun";
import { config } from "./config";
import { closeAllConnections } from "./transport/ws-handler";
import { startDisconnectMonitor } from "./services/disconnect-monitor";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
// Routes
import v1Environments from "./routes/v1/environments";
import v1EnvironmentsWork from "./routes/v1/environments.work";
import v1Sessions from "./routes/v1/sessions";
import v1SessionIngress, { websocket } from "./routes/v1/session-ingress";
import v2CodeSessions from "./routes/v2/code-sessions";
import v2Worker from "./routes/v2/worker";
import v2WorkerEventsStream from "./routes/v2/worker-events-stream";
import v2WorkerEvents from "./routes/v2/worker-events";
import webAuth from "./routes/web/auth";
import webSessions from "./routes/web/sessions";
import webControl from "./routes/web/control";
import webEnvironments from "./routes/web/environments";
console.log("[RCS] In-memory store ready (no SQLite)");
const app = new Hono();
// Middleware
app.use("*", logger());
app.use("/web/*", cors());
// Health check
app.get("/health", (c) => c.json({ status: "ok", version: config.version }));
// Static files — serve web/ directory under /code path
const __dirname = dirname(fileURLToPath(import.meta.url));
const webDir = resolve(__dirname, "../web");
const stripCodePrefix = (p: string) => p.replace(/^\/code/, "");
// Serve all static files under /code/* from web/ directory
app.use("/code/*", serveStatic({ root: webDir, rewriteRequestPath: stripCodePrefix }));
// /code, /code/, and /code/:sessionId — SPA fallback
app.get("/code", serveStatic({ root: webDir, path: "index.html" }));
app.get("/code/", serveStatic({ root: webDir, path: "index.html" }));
app.get("/code/:sessionId", serveStatic({ root: webDir, path: "index.html" }));
// v1 Environment routes
app.route("/v1/environments", v1Environments);
app.route("/v1/environments", v1EnvironmentsWork);
// v1 Session routes
app.route("/v1/sessions", v1Sessions);
// Session Ingress (WebSocket) — mounted at both /v1 and /v2 so the bridge
// client's buildSdkUrl works with or without an Envoy proxy rewriting /v1→/v2.
app.route("/v1/session_ingress", v1SessionIngress);
app.route("/v2/session_ingress", v1SessionIngress);
// v2 Code Sessions routes
app.route("/v1/code/sessions", v2CodeSessions);
app.route("/v1/code/sessions", v2Worker);
app.route("/v1/code/sessions", v2WorkerEventsStream);
app.route("/v1/code/sessions", v2WorkerEvents);
// Web control panel routes
app.route("/web", webAuth);
app.route("/web", webSessions);
app.route("/web", webControl);
app.route("/web", webEnvironments);
const port = config.port;
const host = config.host;
console.log(`[RCS] Remote Control Server starting on ${host}:${port}`);
console.log("[RCS] API key configuration loaded");
console.log(`[RCS] Base URL: ${config.baseUrl || `http://localhost:${port}`}`);
console.log(`[RCS] Disconnect timeout: ${config.disconnectTimeout}s`);
// Start disconnect monitor
startDisconnectMonitor();
export default {
port,
hostname: host,
fetch: app.fetch,
websocket: {
...websocket,
idleTimeout: 255, // WS idle timeout (seconds) — must be inside websocket object
},
idleTimeout: 255, // HTTP server idle timeout (seconds) — needed for long-polling endpoints
};
// Graceful shutdown
async function gracefulShutdown(signal: string) {
console.log(`\n[RCS] Received ${signal}, shutting down...`);
closeAllConnections();
process.exit(0);
}
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));

View 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;

View File

@@ -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;

View 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;

View 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;

View File

@@ -0,0 +1,36 @@
import { Hono } from "hono";
import { createCodeSession, getSession, incrementEpoch } from "../../services/session";
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
import { generateWorkerJwt } from "../../auth/jwt";
import { getBaseUrl, config } from "../../config";
const app = new Hono();
/** POST /v1/code/sessions — Create code session (wrapped response for TUI compat) */
app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
const body = await c.req.json();
const session = createCodeSession(body);
return c.json({ session }, 200);
});
/** POST /v1/code/sessions/:id/bridge — Get connection info + worker JWT */
app.post("/:id/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = c.req.param("id");
const session = getSession(sessionId);
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
const epoch = incrementEpoch(sessionId);
const expiresInSeconds = config.jwtExpiresIn;
const workerJwt = generateWorkerJwt(sessionId, expiresInSeconds);
return c.json({
api_base_url: getBaseUrl(),
worker_epoch: epoch,
worker_jwt: workerJwt,
expires_in: expiresInSeconds,
}, 200);
});
export default app;

View File

@@ -0,0 +1,24 @@
import { Hono } from "hono";
import { sessionIngressAuth, acceptCliHeaders } from "../../auth/middleware";
import { createSSEStream } from "../../transport/sse-writer";
import { getSession } from "../../services/session";
const app = new Hono();
/** SSE /v1/code/sessions/:id/worker/events/stream — SSE event stream */
app.get("/:id/worker/events/stream", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id");
const session = getSession(sessionId);
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
// Support Last-Event-ID / from_sequence_num for reconnection
const lastEventId = c.req.header("Last-Event-ID");
const fromSeq = c.req.query("from_sequence_num");
const fromSeqNum = fromSeq ? parseInt(fromSeq) : lastEventId ? parseInt(lastEventId) : 0;
return createSSEStream(c, sessionId, fromSeqNum);
});
export default app;

View File

@@ -0,0 +1,48 @@
import { Hono } from "hono";
import { sessionIngressAuth, acceptCliHeaders } from "../../auth/middleware";
import { publishSessionEvent } from "../../services/transport";
import { getSession, updateSessionStatus } from "../../services/session";
const app = new Hono();
/** POST /v1/code/sessions/:id/worker/events — Write events */
app.post("/:id/worker/events", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id");
const body = await c.req.json();
const 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", count: published.length }, 200);
});
/** PUT /v1/code/sessions/:id/worker/state — Report worker state */
app.put("/:id/worker/state", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id");
const body = await c.req.json();
if (body.status) {
updateSessionStatus(sessionId, body.status);
}
return c.json({ status: "ok" }, 200);
});
/** PUT /v1/code/sessions/:id/worker/external_metadata — Report worker metadata (no-op) */
app.put("/:id/worker/external_metadata", acceptCliHeaders, sessionIngressAuth, async (c) => {
// TUI's CCRClient calls this for metadata reporting. Accept and discard.
return c.json({ status: "ok" }, 200);
});
/** POST /v1/code/sessions/:id/worker/events/:eventId/delivery — Delivery tracking (no-op) */
app.post("/:id/worker/events/:eventId/delivery", acceptCliHeaders, sessionIngressAuth, async (c) => {
// TUI's CCRClient reports event delivery status (received/processing/processed).
// Accept and discard — event bus doesn't track per-event delivery.
return c.json({ status: "ok" }, 200);
});
export default app;

View File

@@ -0,0 +1,19 @@
import { Hono } from "hono";
import { getSession, incrementEpoch } from "../../services/session";
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
const app = new Hono();
/** POST /v1/code/sessions/:id/worker/register — Register worker */
app.post("/:id/worker/register", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = c.req.param("id");
const session = getSession(sessionId);
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
const epoch = incrementEpoch(sessionId);
return c.json({ worker_epoch: epoch }, 200);
});
export default app;

View File

@@ -0,0 +1,26 @@
import { Hono } from "hono";
import { storeGetSession, storeBindSession } from "../../store";
const app = new Hono();
/** POST /web/bind — Bind a session to a UUID (no-login auth) */
app.post("/bind", async (c) => {
const body = await c.req.json();
const sessionId = body.sessionId;
// UUID can come from query param (api.js sends it in URL) or body
const uuid = c.req.query("uuid") || body.uuid;
if (!sessionId || !uuid) {
return c.json({ error: "sessionId and uuid are required" }, 400);
}
const session = storeGetSession(sessionId);
if (!session) {
return c.json({ error: "Session not found" }, 404);
}
storeBindSession(sessionId, uuid);
return c.json({ ok: true, sessionId });
});
export default app;

View File

@@ -0,0 +1,64 @@
import { Hono } from "hono";
import { uuidAuth } from "../../auth/middleware";
import { getSession, updateSessionStatus } from "../../services/session";
import { publishSessionEvent } from "../../services/transport";
import { getEventBus } from "../../transport/event-bus";
import { storeIsSessionOwner } from "../../store";
const app = new Hono();
function checkOwnership(c: { get: (key: string) => string | undefined }, sessionId: string) {
const uuid = c.get("uuid");
if (!storeIsSessionOwner(sessionId, uuid)) {
return { error: true, session: null };
}
const session = getSession(sessionId);
if (!session) {
return { error: true, session: null };
}
return { error: false, session };
}
/** POST /web/sessions/:id/events — Send user message to session */
app.post("/sessions/:id/events", uuidAuth, async (c) => {
const sessionId = c.req.param("id")!;
const { error } = checkOwnership(c, sessionId);
if (error) {
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
}
const body = await c.req.json();
const eventType = body.type || "user";
console.log(`[RC-DEBUG] web -> server: POST /web/sessions/${sessionId}/events type=${eventType} content=${JSON.stringify(body).slice(0, 200)}`);
const event = publishSessionEvent(sessionId, eventType, body, "outbound");
console.log(`[RC-DEBUG] web -> server: published outbound event id=${event.id} type=${event.type} direction=${event.direction} subscribers=${getEventBus(sessionId).subscriberCount()}`);
return c.json({ status: "ok", event }, 200);
});
/** POST /web/sessions/:id/control — Send control request (permission approval etc) */
app.post("/sessions/:id/control", uuidAuth, async (c) => {
const sessionId = c.req.param("id")!;
const { error } = checkOwnership(c, sessionId);
if (error) {
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
}
const body = await c.req.json();
const event = publishSessionEvent(sessionId, body.type || "control_request", body, "outbound");
return c.json({ status: "ok", event }, 200);
});
/** POST /web/sessions/:id/interrupt — Interrupt session */
app.post("/sessions/:id/interrupt", uuidAuth, async (c) => {
const sessionId = c.req.param("id")!;
const { error } = checkOwnership(c, sessionId);
if (error) {
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
}
publishSessionEvent(sessionId, "interrupt", { action: "interrupt" }, "outbound");
updateSessionStatus(sessionId, "idle");
return c.json({ status: "ok" }, 200);
});
export default app;

View File

@@ -0,0 +1,14 @@
import { Hono } from "hono";
import { uuidAuth } from "../../auth/middleware";
import { listActiveEnvironmentsResponse } from "../../services/environment";
const app = new Hono();
/** GET /web/environments — List active environments (UUID-based, no user filtering) */
app.get("/environments", uuidAuth, async (c) => {
// Environments are shared across all UUIDs for now
const envs = listActiveEnvironmentsResponse();
return c.json(envs, 200);
});
export default app;

View File

@@ -0,0 +1,100 @@
import { Hono } from "hono";
import { uuidAuth } from "../../auth/middleware";
import { getSession, createSession } from "../../services/session";
import { storeListSessionsByOwnerUuid, storeIsSessionOwner, storeBindSession } from "../../store";
import { createWorkItem } from "../../services/work-dispatch";
import { listSessionSummariesByOwnerUuid } from "../../services/session";
import { createSSEStream } from "../../transport/sse-writer";
import { getEventBus } from "../../transport/event-bus";
const app = new Hono();
/** POST /web/sessions — Create a session from web UI */
app.post("/sessions", uuidAuth, async (c) => {
const uuid = c.get("uuid");
const body = await c.req.json();
const session = createSession({
environment_id: body.environment_id || null,
title: body.title || "New Session",
source: "web",
permission_mode: body.permission_mode || "default",
});
// Auto-bind to creator's UUID
storeBindSession(session.id, uuid);
// Dispatch work to environment if 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}`);
}
}
return c.json(session, 200);
});
/** GET /web/sessions — List sessions owned by the requesting UUID */
app.get("/sessions", uuidAuth, async (c) => {
const uuid = c.get("uuid");
const sessions = storeListSessionsByOwnerUuid(uuid);
return c.json(sessions, 200);
});
/** GET /web/sessions/all — List sessions owned by the requesting UUID (unowned sessions excluded) */
app.get("/sessions/all", uuidAuth, async (c) => {
const uuid = c.get("uuid");
const sessions = listSessionSummariesByOwnerUuid(uuid);
return c.json(sessions, 200);
});
/** GET /web/sessions/:id — Session detail */
app.get("/sessions/:id", uuidAuth, async (c) => {
const uuid = c.get("uuid");
const sessionId = c.req.param("id")!;
if (!storeIsSessionOwner(sessionId, uuid)) {
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
}
const session = getSession(sessionId);
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
return c.json(session, 200);
});
/** GET /web/sessions/:id/history — Historical events for session */
app.get("/sessions/:id/history", uuidAuth, async (c) => {
const uuid = c.get("uuid");
const sessionId = c.req.param("id")!;
if (!storeIsSessionOwner(sessionId, uuid)) {
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
}
const session = getSession(sessionId);
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
const bus = getEventBus(sessionId);
const events = bus.getEventsSince(0);
return c.json({ events }, 200);
});
/** SSE /web/sessions/:id/events — Real-time event stream */
app.get("/sessions/:id/events", uuidAuth, async (c) => {
const uuid = c.get("uuid");
const sessionId = c.req.param("id")!;
if (!storeIsSessionOwner(sessionId, uuid)) {
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
}
const session = getSession(sessionId);
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
const lastEventId = c.req.header("Last-Event-ID");
const fromSeqNum = lastEventId ? parseInt(lastEventId) : 0;
return createSSEStream(c, sessionId, fromSeqNum);
});
export default app;

View File

@@ -0,0 +1,32 @@
import { storeListActiveEnvironments, storeUpdateEnvironment } from "../store";
import { storeListSessions, storeUpdateSession } from "../store";
import { config } from "../config";
export function startDisconnectMonitor() {
const timeoutMs = config.disconnectTimeout * 1000;
setInterval(() => {
const now = Date.now();
// Check environment heartbeat timeout
const envs = storeListActiveEnvironments();
for (const env of envs) {
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
console.log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
storeUpdateEnvironment(env.id, { status: "disconnected" });
}
}
// Check session timeout (2x disconnect timeout with no update)
const sessions = storeListSessions();
for (const session of sessions) {
if (session.status === "running" || session.status === "idle") {
const elapsed = now - session.updatedAt.getTime();
if (elapsed > timeoutMs * 2) {
console.log(`[RCS] Session ${session.id} marked inactive (no update for ${Math.round(elapsed / 1000)}s)`);
storeUpdateSession(session.id, { status: "inactive" });
}
}
}
}, 60_000); // Check every minute
}

View File

@@ -0,0 +1,68 @@
import { config } from "../config";
import {
storeCreateEnvironment,
storeGetEnvironment,
storeUpdateEnvironment,
storeListActiveEnvironments,
storeListActiveEnvironmentsByUsername,
} from "../store";
import type { RegisterEnvironmentRequest, EnvironmentResponse } from "../types/api";
import type { EnvironmentRecord } from "../store";
function toResponse(row: EnvironmentRecord): EnvironmentResponse {
return {
id: row.id,
machine_name: row.machineName,
directory: row.directory,
branch: row.branch,
status: row.status,
username: row.username,
last_poll_at: row.lastPollAt ? row.lastPollAt.getTime() / 1000 : null,
};
}
export function registerEnvironment(req: RegisterEnvironmentRequest & { metadata?: { worker_type?: string }; username?: string }) {
const secret = config.apiKeys[0] || "";
const workerType = req.worker_type || req.metadata?.worker_type;
const record = storeCreateEnvironment({
secret,
machineName: req.machine_name,
directory: req.directory,
branch: req.branch,
gitRepoUrl: req.git_repo_url,
maxSessions: req.max_sessions,
workerType,
bridgeId: req.bridge_id,
username: req.username,
});
return { environment_id: record.id, environment_secret: record.secret, status: record.status as "active" };
}
export function deregisterEnvironment(envId: string) {
storeUpdateEnvironment(envId, { status: "deregistered" });
}
export function getEnvironment(envId: string) {
return storeGetEnvironment(envId);
}
export function updatePollTime(envId: string) {
storeUpdateEnvironment(envId, { lastPollAt: new Date() });
}
export function listActiveEnvironments() {
return storeListActiveEnvironments();
}
export function listActiveEnvironmentsResponse(): EnvironmentResponse[] {
return storeListActiveEnvironments().map(toResponse);
}
export function listActiveEnvironmentsByUsername(username: string): EnvironmentResponse[] {
return storeListActiveEnvironmentsByUsername(username).map(toResponse);
}
export function reconnectEnvironment(envId: string) {
storeUpdateEnvironment(envId, { status: "active" });
}

View File

@@ -0,0 +1,103 @@
import {
storeCreateSession,
storeGetSession,
storeUpdateSession,
storeListSessions,
storeListSessionsByUsername,
storeListSessionsByEnvironment,
storeListSessionsByOwnerUuid,
} from "../store";
import { removeEventBus } from "../transport/event-bus";
import type { CreateSessionRequest, CreateCodeSessionRequest, SessionResponse, SessionSummaryResponse } from "../types/api";
function toResponse(row: { id: string; environmentId: string | null; title: string | null; status: string; source: string; permissionMode: string | null; workerEpoch: number; username: string | null; createdAt: Date; updatedAt: Date }): SessionResponse {
return {
id: row.id,
environment_id: row.environmentId,
title: row.title,
status: row.status,
source: row.source,
permission_mode: row.permissionMode,
worker_epoch: row.workerEpoch,
username: row.username,
created_at: row.createdAt.getTime() / 1000,
updated_at: row.updatedAt.getTime() / 1000,
};
}
export function createSession(req: CreateSessionRequest & { username?: string }): SessionResponse {
const record = storeCreateSession({
environmentId: req.environment_id,
title: req.title,
source: req.source,
permissionMode: req.permission_mode,
username: req.username,
});
return toResponse(record);
}
export function createCodeSession(req: CreateCodeSessionRequest): SessionResponse {
const record = storeCreateSession({
idPrefix: "cse_",
title: req.title,
source: req.source,
permissionMode: req.permission_mode,
});
return toResponse(record);
}
export function getSession(sessionId: string): SessionResponse | null {
const record = storeGetSession(sessionId);
return record ? toResponse(record) : null;
}
export function updateSessionTitle(sessionId: string, title: string) {
storeUpdateSession(sessionId, { title });
}
export function updateSessionStatus(sessionId: string, status: string) {
storeUpdateSession(sessionId, { status });
}
export function archiveSession(sessionId: string) {
storeUpdateSession(sessionId, { status: "archived" });
removeEventBus(sessionId);
}
export function incrementEpoch(sessionId: string): number {
const record = storeGetSession(sessionId);
if (!record) throw new Error("Session not found");
const newEpoch = record.workerEpoch + 1;
storeUpdateSession(sessionId, { workerEpoch: newEpoch });
return newEpoch;
}
export function listSessions() {
return storeListSessions().map(toResponse);
}
function toSummaryResponse(row: { id: string; title: string | null; status: string; username: string | null; updatedAt: Date }): SessionSummaryResponse {
return {
id: row.id,
title: row.title,
status: row.status,
username: row.username,
updated_at: row.updatedAt.getTime() / 1000,
};
}
export function listSessionSummaries(): SessionSummaryResponse[] {
return storeListSessions().map(toSummaryResponse);
}
export function listSessionSummariesByOwnerUuid(uuid: string): SessionSummaryResponse[] {
return storeListSessionsByOwnerUuid(uuid).map(toSummaryResponse);
}
export function listSessionSummariesByUsername(username: string): SessionSummaryResponse[] {
return storeListSessionsByUsername(username).map(toSummaryResponse);
}
export function listSessionsByEnvironment(envId: string) {
return storeListSessionsByEnvironment(envId).map(toResponse);
}

View File

@@ -0,0 +1,93 @@
import { getEventBus } from "../transport/event-bus";
import { v4 as uuid } from "uuid";
/**
* Extract plain text from various message payload formats.
* Handles:
* { content: "text" }
* { message: { role: "user", content: "text" } }
* { message: { content: [{type:"text",text:"..."}] } }
*/
function extractContent(payload: unknown): string {
if (!payload || typeof payload !== "object") {
return typeof payload === "string" ? payload : "";
}
const p = payload as Record<string, unknown>;
// Direct content field
if (typeof p.content === "string" && p.content) return p.content;
// message.content (child process format)
const msg = p.message;
if (msg && typeof msg === "object") {
const mc = (msg as Record<string, unknown>).content;
if (typeof mc === "string") return mc;
if (Array.isArray(mc)) {
return mc
.filter((b: unknown) => typeof b === "object" && b !== null && (b as Record<string, unknown>).type === "text")
.map((b: Record<string, unknown>) => (b as Record<string, unknown>).text || "")
.join("");
}
}
return "";
}
/**
* Normalize event payload into a flat structure with guaranteed `content` string.
* Preserves original payload in `raw` field and keeps tool-specific fields.
*/
export function normalizePayload(type: string, payload: unknown): Record<string, unknown> {
if (!payload || typeof payload !== "object") {
return { content: typeof payload === "string" ? payload : "", raw: payload };
}
const p = payload as Record<string, unknown>;
const content = extractContent(payload);
const normalized: Record<string, unknown> = {
content,
raw: payload,
};
// Preserve tool fields
if (p.tool_name) normalized.tool_name = p.tool_name;
if (p.name) normalized.tool_name = p.name;
if (p.tool_input) normalized.tool_input = p.tool_input;
if (p.input) normalized.tool_input = p.input;
// Preserve permission fields
if (p.request_id) normalized.request_id = p.request_id;
if (p.request) normalized.request = p.request;
if (p.approved !== undefined) normalized.approved = p.approved;
if (p.updated_input) normalized.updated_input = p.updated_input;
// Preserve message field for backward compat
if (p.message) normalized.message = p.message;
return normalized;
}
/** Publish an event to a session's bus (in-memory only) */
export function publishSessionEvent(
sessionId: string,
type: string,
payload: unknown,
direction: "inbound" | "outbound",
) {
const bus = getEventBus(sessionId);
const eventId = uuid();
const normalized = normalizePayload(type, payload);
const event = bus.publish({
id: eventId,
sessionId,
type,
payload: normalized,
direction,
});
return event;
}

View File

@@ -0,0 +1,98 @@
import {
storeCreateWorkItem,
storeGetWorkItem,
storeGetPendingWorkItem,
storeUpdateWorkItem,
storeListSessionsByEnvironment,
storeGetEnvironment,
} from "../store";
import { config } from "../config";
import { getBaseUrl } from "../config";
import type { WorkResponse } from "../types/api";
/** Encode work secret as base64 JSON (no JWT — just API key as token) */
function encodeWorkSecret(): string {
const payload = {
version: 1,
session_ingress_token: config.apiKeys[0] || "",
api_base_url: getBaseUrl(),
sources: [] as string[],
auth: [] as string[],
use_code_sessions: false,
};
return Buffer.from(JSON.stringify(payload)).toString("base64url");
}
export async function createWorkItem(environmentId: string, sessionId: string): Promise<string> {
// Validate environment exists and is active
const env = storeGetEnvironment(environmentId);
if (!env) {
throw new Error(`Environment ${environmentId} not found`);
}
if (env.status !== "active") {
throw new Error(`Environment ${environmentId} is not active (status: ${env.status})`);
}
const secret = encodeWorkSecret();
const record = storeCreateWorkItem({ environmentId, sessionId, secret });
console.log(`[RCS] Work item created: ${record.id} for env=${environmentId} session=${sessionId}`);
return record.id;
}
/** Long-poll for work — blocks until work is available or timeout.
* Returns null when no work is available, matching the CLI bridge client protocol. */
export async function pollWork(environmentId: string, timeoutSeconds = config.pollTimeout): Promise<WorkResponse | null> {
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
const item = storeGetPendingWorkItem(environmentId);
if (item) {
storeUpdateWorkItem(item.id, { state: "dispatched" });
return {
id: item.id,
type: "work",
environment_id: environmentId,
state: "dispatched",
data: {
type: "session",
id: item.sessionId,
},
secret: item.secret,
created_at: item.createdAt.toISOString(),
};
}
await new Promise((r) => setTimeout(r, 500));
}
return null;
}
export function ackWork(workId: string) {
storeUpdateWorkItem(workId, { state: "acked" });
}
export function stopWork(workId: string) {
storeUpdateWorkItem(workId, { state: "completed" });
}
export function heartbeatWork(workId: string): { lease_extended: boolean; state: string; last_heartbeat: string; ttl_seconds: number } {
storeUpdateWorkItem(workId, {} as any); // just bump updatedAt
const item = storeGetWorkItem(workId);
const now = new Date();
return {
lease_extended: true,
state: item?.state ?? "acked",
last_heartbeat: now.toISOString(),
ttl_seconds: config.heartbeatInterval * 2,
};
}
/** Reconnect: re-queue sessions associated with an environment */
export function reconnectWorkForEnvironment(envId: string) {
const activeSessions = storeListSessionsByEnvironment(envId).filter((s) => s.status === "idle");
const promises = activeSessions.map((s) => createWorkItem(envId, s.id));
return Promise.all(promises);
}

View File

@@ -0,0 +1,276 @@
import { v4 as uuid } from "uuid";
// ---------- Types ----------
export interface UserRecord {
username: string;
createdAt: Date;
}
export interface EnvironmentRecord {
id: string;
secret: string;
machineName: string | null;
directory: string | null;
branch: string | null;
gitRepoUrl: string | null;
maxSessions: number;
workerType: string;
bridgeId: string | null;
status: string;
username: string | null;
lastPollAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface SessionRecord {
id: string;
environmentId: string | null;
title: string | null;
status: string;
source: string;
permissionMode: string | null;
workerEpoch: number;
username: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface WorkItemRecord {
id: string;
environmentId: string;
sessionId: string;
state: string;
secret: string;
createdAt: Date;
updatedAt: Date;
}
// ---------- Stores (in-memory Maps) ----------
const users = new Map<string, UserRecord>();
const tokenToUser = new Map<string, { username: string; createdAt: Date }>();
const environments = new Map<string, EnvironmentRecord>();
const sessions = new Map<string, SessionRecord>();
const workItems = new Map<string, WorkItemRecord>();
// UUID → session ownership: sessionId → Set of UUIDs
const sessionOwners = new Map<string, Set<string>>();
// ---------- User ----------
export function storeCreateUser(username: string): UserRecord {
const existing = users.get(username);
if (existing) return existing;
const record: UserRecord = { username, createdAt: new Date() };
users.set(username, record);
return record;
}
export function storeGetUser(username: string): UserRecord | undefined {
return users.get(username);
}
export function storeCreateToken(username: string, token: string): void {
tokenToUser.set(token, { username, createdAt: new Date() });
}
export function storeGetUserByToken(token: string): { username: string; createdAt: Date } | undefined {
return tokenToUser.get(token);
}
export function storeDeleteToken(token: string): boolean {
return tokenToUser.delete(token);
}
// ---------- Environment ----------
export function storeCreateEnvironment(req: {
secret: string;
machineName?: string;
directory?: string;
branch?: string;
gitRepoUrl?: string;
maxSessions?: number;
workerType?: string;
bridgeId?: string;
username?: string;
}): EnvironmentRecord {
const id = `env_${uuid().replace(/-/g, "")}`;
const now = new Date();
const record: EnvironmentRecord = {
id,
secret: req.secret,
machineName: req.machineName ?? null,
directory: req.directory ?? null,
branch: req.branch ?? null,
gitRepoUrl: req.gitRepoUrl ?? null,
maxSessions: req.maxSessions ?? 1,
workerType: req.workerType ?? "claude_code",
bridgeId: req.bridgeId ?? null,
status: "active",
username: req.username ?? null,
lastPollAt: now,
createdAt: now,
updatedAt: now,
};
environments.set(id, record);
return record;
}
export function storeGetEnvironment(id: string): EnvironmentRecord | undefined {
return environments.get(id);
}
export function storeUpdateEnvironment(id: string, patch: Partial<Pick<EnvironmentRecord, "status" | "lastPollAt" | "updatedAt">>): boolean {
const rec = environments.get(id);
if (!rec) return false;
Object.assign(rec, patch, { updatedAt: new Date() });
return true;
}
export function storeListActiveEnvironments(): EnvironmentRecord[] {
return [...environments.values()].filter((e) => e.status === "active");
}
export function storeListActiveEnvironmentsByUsername(username: string): EnvironmentRecord[] {
return [...environments.values()].filter((e) => e.status === "active" && e.username === username);
}
// ---------- Session ----------
export function storeCreateSession(req: {
environmentId?: string | null;
title?: string | null;
source?: string;
permissionMode?: string | null;
idPrefix?: string;
username?: string | null;
}): SessionRecord {
const id = `${req.idPrefix || "session_"}${uuid().replace(/-/g, "")}`;
const now = new Date();
const record: SessionRecord = {
id,
environmentId: req.environmentId ?? null,
title: req.title ?? null,
status: "idle",
source: req.source ?? "remote-control",
permissionMode: req.permissionMode ?? null,
workerEpoch: 0,
username: req.username ?? null,
createdAt: now,
updatedAt: now,
};
sessions.set(id, record);
return record;
}
export function storeGetSession(id: string): SessionRecord | undefined {
return sessions.get(id);
}
export function storeUpdateSession(id: string, patch: Partial<Pick<SessionRecord, "title" | "status" | "workerEpoch" | "updatedAt">>): boolean {
const rec = sessions.get(id);
if (!rec) return false;
Object.assign(rec, patch, { updatedAt: new Date() });
return true;
}
export function storeListSessions(): SessionRecord[] {
return [...sessions.values()];
}
export function storeListSessionsByUsername(username: string): SessionRecord[] {
return [...sessions.values()].filter((s) => s.username === username);
}
export function storeListSessionsByEnvironment(envId: string): SessionRecord[] {
return [...sessions.values()].filter((s) => s.environmentId === envId);
}
export function storeDeleteSession(id: string): boolean {
return sessions.delete(id);
}
// ---------- Work Items ----------
// ---------- Session Ownership (UUID-based) ----------
export function storeBindSession(sessionId: string, uuid: string): void {
let owners = sessionOwners.get(sessionId);
if (!owners) {
owners = new Set();
sessionOwners.set(sessionId, owners);
}
owners.add(uuid);
}
export function storeIsSessionOwner(sessionId: string, uuid: string): boolean {
const owners = sessionOwners.get(sessionId);
return owners ? owners.has(uuid) : false;
}
export function storeListSessionsByOwnerUuid(uuid: string): SessionRecord[] {
const result: SessionRecord[] = [];
for (const [sessionId, owners] of sessionOwners) {
if (owners.has(uuid)) {
const session = sessions.get(sessionId);
if (session) result.push(session);
}
}
return result;
}
// ---------- Work Items (cont.) ----------
export function storeCreateWorkItem(req: {
environmentId: string;
sessionId: string;
secret: string;
}): WorkItemRecord {
const id = `work_${uuid().replace(/-/g, "")}`;
const now = new Date();
const record: WorkItemRecord = {
id,
environmentId: req.environmentId,
sessionId: req.sessionId,
state: "pending",
secret: req.secret,
createdAt: now,
updatedAt: now,
};
workItems.set(id, record);
return record;
}
export function storeGetWorkItem(id: string): WorkItemRecord | undefined {
return workItems.get(id);
}
export function storeGetPendingWorkItem(environmentId: string): WorkItemRecord | undefined {
for (const item of workItems.values()) {
if (item.environmentId === environmentId && item.state === "pending") {
return item;
}
}
return undefined;
}
export function storeUpdateWorkItem(id: string, patch: Partial<Pick<WorkItemRecord, "state" | "updatedAt">>): boolean {
const rec = workItems.get(id);
if (!rec) return false;
Object.assign(rec, patch, { updatedAt: new Date() });
return true;
}
// ---------- Reset (for tests) ----------
export function storeReset() {
users.clear();
tokenToUser.clear();
environments.clear();
sessions.clear();
workItems.clear();
sessionOwners.clear();
}

View File

@@ -0,0 +1,85 @@
export interface SessionEvent {
id: string;
sessionId: string;
type: string;
payload: unknown;
direction: "inbound" | "outbound";
seqNum: number;
createdAt: number;
}
type Subscriber = (event: SessionEvent) => void;
export class EventBus {
private subscribers = new Set<Subscriber>();
private events: SessionEvent[] = [];
private seqNum = 0;
private closed = false;
subscribe(callback: Subscriber): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
subscriberCount(): number {
return this.subscribers.size;
}
publish(event: Omit<SessionEvent, "seqNum" | "createdAt">): SessionEvent {
if (this.closed) throw new Error("EventBus is closed");
const full: SessionEvent = {
...event,
seqNum: ++this.seqNum,
createdAt: Date.now(),
};
this.events.push(full);
console.log(`[RC-DEBUG] bus publish: sessionId=${event.sessionId} type=${event.type} dir=${event.direction} seq=${full.seqNum} subscribers=${this.subscribers.size}`);
for (const cb of this.subscribers) {
try {
cb(full);
} catch (err) {
console.error(`[RC-DEBUG] bus subscriber error:`, err);
}
}
return full;
}
getLastSeqNum(): number {
return this.seqNum;
}
getEventsSince(seqNum: number): SessionEvent[] {
const idx = this.events.findIndex((e) => e.seqNum > seqNum);
if (idx === -1) return [];
return this.events.slice(idx);
}
close() {
this.closed = true;
this.subscribers.clear();
}
}
/** Global registry of per-session event buses */
const buses = new Map<string, EventBus>();
export function getEventBus(sessionId: string): EventBus {
let bus = buses.get(sessionId);
if (!bus) {
bus = new EventBus();
buses.set(sessionId, bus);
}
return bus;
}
export function removeEventBus(sessionId: string) {
const bus = buses.get(sessionId);
if (bus) {
bus.close();
buses.delete(sessionId);
}
}
export function getAllEventBuses(): Map<string, EventBus> {
return buses;
}

View File

@@ -0,0 +1,117 @@
import type { Context } from "hono";
import type { SessionEvent } from "./event-bus";
import { getEventBus } from "./event-bus";
export interface SSEWriter {
send(event: SessionEvent): void;
close(): void;
}
export function createSSEWriter(c: Context): SSEWriter {
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
c.req.raw.signal.addEventListener("abort", () => {
controller.close();
});
// Store encoder and controller for later use
(c as any)._sseEncoder = encoder;
(c as any)._sseController = controller;
},
});
return {
send(event: SessionEvent) {
const encoder = (c as any)._sseEncoder as TextEncoder;
const controller = (c as any)._sseController as ReadableStreamDefaultController;
if (!encoder || !controller) return;
const data = JSON.stringify({
type: event.type,
payload: event.payload,
direction: event.direction,
seqNum: event.seqNum,
});
const msg = `id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`;
controller.enqueue(encoder.encode(msg));
},
close() {
const controller = (c as any)._sseController as ReadableStreamDefaultController;
controller?.close();
},
};
}
/** Create SSE response stream for a session */
export function createSSEStream(c: Context, sessionId: string, fromSeqNum = 0) {
const bus = getEventBus(sessionId);
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
// Send historical events if reconnecting
if (fromSeqNum > 0) {
const missed = bus.getEventsSince(fromSeqNum);
for (const event of missed) {
const data = JSON.stringify({
type: event.type,
payload: event.payload,
direction: event.direction,
seqNum: event.seqNum,
});
controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`));
}
}
// Send initial keepalive
controller.enqueue(encoder.encode(": keepalive\n\n"));
// Subscribe to new events
const unsub = bus.subscribe((event) => {
const data = JSON.stringify({
type: event.type,
payload: event.payload,
direction: event.direction,
seqNum: event.seqNum,
});
try {
console.log(`[RC-DEBUG] SSE -> web: sessionId=${sessionId} type=${event.type} dir=${event.direction} seq=${event.seqNum}`);
controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`));
} catch {
unsub();
}
});
// Keepalive interval
const keepalive = setInterval(() => {
try {
controller.enqueue(encoder.encode(": keepalive\n\n"));
} catch {
clearInterval(keepalive);
unsub();
}
}, 15000);
// Cleanup on abort
c.req.raw.signal.addEventListener("abort", () => {
unsub();
clearInterval(keepalive);
try {
controller.close();
} catch {
// already closed
}
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}

View File

@@ -0,0 +1,273 @@
import type { WSContext } from "hono/ws";
import { getEventBus } from "./event-bus";
import type { SessionEvent } from "./event-bus";
import { publishSessionEvent } from "../services/transport";
// Per-connection cleanup, keyed by sessionId (only one WS per session)
interface CleanupEntry {
unsub: () => void;
keepalive: ReturnType<typeof setInterval>;
ws: WSContext;
openTime: number;
}
const cleanupBySession = new Map<string, CleanupEntry>();
// Track all active WS connections for graceful shutdown
const activeConnections = new Set<WSContext>();
// Bridge sends keep_alive data frames every 120s. Send server-side keep_alive
// every 60s to ensure the connection stays alive even without user messages.
const SERVER_KEEPALIVE_INTERVAL_MS = 60_000;
/**
* Convert internal EventBus event -> SDK message for bridge client.
*/
function toSDKMessage(event: SessionEvent): string {
const payload = event.payload as Record<string, unknown> | null;
let msg: Record<string, unknown>;
if (event.type === "user" || event.type === "user_message") {
msg = {
type: "user",
uuid: event.id,
session_id: event.sessionId,
message: {
role: "user",
content: payload?.content ?? payload?.message ?? "",
},
};
} else if (event.type === "permission_response" || event.type === "control_response") {
const approved = !!payload?.approved;
const existingResponse = payload?.response as Record<string, unknown> | undefined;
if (existingResponse) {
msg = { type: "control_response", response: existingResponse };
} else {
const updatedInput = payload?.updated_input as Record<string, unknown> | undefined;
const updatedPermissions = payload?.updated_permissions as Record<string, unknown>[] | undefined;
const feedbackMessage = payload?.message as string | undefined;
msg = {
type: "control_response",
response: {
subtype: approved ? "success" : "error",
request_id: payload?.request_id ?? "",
...(approved
? {
response: {
behavior: "allow" as const,
...(updatedInput ? { updatedInput } : {}),
...(updatedPermissions ? { updatedPermissions } : {}),
},
}
: {
error: "Permission denied by user",
response: { behavior: "deny" as const },
...(feedbackMessage ? { message: feedbackMessage } : {}),
}),
},
};
}
} else if (event.type === "interrupt") {
msg = {
type: "control_request",
request_id: event.id,
request: { subtype: "interrupt" },
};
} else if (event.type === "control_request") {
msg = {
type: "control_request",
request_id: payload?.request_id ?? event.id,
request: payload?.request ?? payload,
};
} else {
msg = {
type: event.type,
uuid: event.id,
session_id: event.sessionId,
message: payload,
};
}
// NDJSON format: each message MUST end with \n so the child process's
// line-based parser can split messages correctly.
return JSON.stringify(msg) + "\n";
}
/** Called from onOpen — subscribes to event bus, forwards outbound events to bridge WS */
export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
const openTime = Date.now();
console.log(`[RC-DEBUG] [WS] Open session=${sessionId}`);
activeConnections.add(ws);
// If there's an existing connection for this session, clean it up first
const existing = cleanupBySession.get(sessionId);
if (existing) {
console.log(`[WS] Replacing existing connection for session=${sessionId}`);
existing.unsub();
clearInterval(existing.keepalive);
activeConnections.delete(existing.ws);
}
const bus = getEventBus(sessionId);
// Replay ALL events (inbound + outbound) so the bridge can reconstruct
// the full conversation history — assistant replies are inbound events.
const missed = bus.getEventsSince(0);
if (missed.length > 0) {
console.log(`[WS] Replaying ${missed.length} missed event(s)`);
for (const event of missed) {
if (ws.readyState !== 1) break;
try {
ws.send(toSDKMessage(event));
} catch {
// ignore send errors during replay
}
}
}
const unsub = bus.subscribe((event: SessionEvent) => {
if (ws.readyState !== 1) return;
if (event.direction !== "outbound") return;
try {
const sdkMsg = toSDKMessage(event);
console.log(`[RC-DEBUG] [WS] -> bridge (outbound): type=${event.type} len=${sdkMsg.length} msg=${sdkMsg.slice(0, 300)}`);
ws.send(sdkMsg);
} catch (err) {
console.error("[RC-DEBUG] [WS] send error:", err);
}
});
const keepalive = setInterval(() => {
if (ws.readyState !== 1) {
clearInterval(keepalive);
return;
}
try {
ws.send('{"type":"keep_alive"}\n');
} catch {
clearInterval(keepalive);
}
}, SERVER_KEEPALIVE_INTERVAL_MS);
cleanupBySession.set(sessionId, { unsub, keepalive, ws, openTime });
}
/**
* Called from onMessage — bridge sends newline-delimited JSON.
*/
export function handleWebSocketMessage(ws: WSContext, sessionId: string, data: string) {
const lines = data.split("\n").filter((l) => l.trim());
for (const line of lines) {
try {
ingestBridgeMessage(sessionId, JSON.parse(line));
} catch (err) {
console.error("[WS] parse error:", err);
}
}
}
/** Called from onClose — unsubscribes from event bus */
export function handleWebSocketClose(ws: WSContext, sessionId: string, code?: number, reason?: string) {
activeConnections.delete(ws);
const entry = cleanupBySession.get(sessionId);
const duration = entry ? Math.round((Date.now() - entry.openTime) / 1000) : -1;
console.log(`[WS] Close session=${sessionId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`);
if (entry) {
entry.unsub();
clearInterval(entry.keepalive);
cleanupBySession.delete(sessionId);
}
}
/**
* Derive event type from a child process message that may lack an explicit
* `type` field. The child's --print --output-format stream-json mode sends:
* {"message":{"role":"user",...},"uuid":"..."} → type "user"
* {"message":{"role":"assistant",...},"uuid":"..."} → type "assistant"
* {"subtype":"success","uuid":"...","result":"..."} → type "result"
*/
function deriveEventType(msg: Record<string, unknown>): string {
if (msg.type && typeof msg.type === "string") return msg.type;
// Child process stream-json format: message.role determines type
const message = msg.message as Record<string, unknown> | undefined;
if (message && typeof message.role === "string") {
return message.role; // "user", "assistant", "system"
}
// Result message
if (msg.subtype || msg.result !== undefined) return "result";
// System/init message
if (msg.session_id) return "system";
return "unknown";
}
/**
* Parse a single SDK message from bridge -> publish to EventBus as inbound.
*/
export function ingestBridgeMessage(sessionId: string, msg: Record<string, unknown>) {
if (msg.type === "keep_alive") return;
const eventType = deriveEventType(msg);
console.log(`[RC-DEBUG] [WS] <- bridge (inbound): sessionId=${sessionId} type=${eventType}${msg.uuid ? ` uuid=${msg.uuid}` : ""} msg=${JSON.stringify(msg).slice(0, 300)}`);
let payload: unknown;
if (eventType === "assistant" || eventType === "partial_assistant") {
const message = msg.message as Record<string, unknown> | undefined;
const content = message?.content;
// Extract text from content blocks for simple display
let text = "";
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
text = content
.filter((b: unknown) => b && typeof b === "object" && "type" in (b as Record<string, unknown>) && (b as Record<string, unknown>).type === "text")
.map((b: Record<string, unknown>) => (b as Record<string, unknown>).text || "")
.join("");
}
payload = { message: msg.message, uuid: msg.uuid, content: text };
} else if (eventType === "user" || eventType === "system") {
payload = { message: msg.message, uuid: msg.uuid };
} else if (eventType === "control_request") {
payload = { request_id: msg.request_id, request: msg.request };
} else if (eventType === "control_response") {
payload = { response: msg.response };
} else if (eventType === "result" || eventType === "result_success") {
payload = { subtype: msg.subtype, uuid: msg.uuid, result: msg.result };
} else {
payload = msg;
}
publishSessionEvent(sessionId, eventType, payload, "inbound");
}
/**
* Gracefully close all active WebSocket connections.
*/
export function closeAllConnections(): void {
const count = activeConnections.size;
if (count === 0) return;
console.log(`[WS] Gracefully closing ${count} active connection(s)...`);
for (const [sessionId, entry] of cleanupBySession) {
try {
entry.unsub();
clearInterval(entry.keepalive);
if (entry.ws.readyState === 1) {
entry.ws.close(1001, "server_shutdown");
}
} catch {
// ignore errors during shutdown
}
}
cleanupBySession.clear();
activeConnections.clear();
console.log("[WS] All connections closed");
}

View File

@@ -0,0 +1,147 @@
/** API 请求/响应类型定义 */
// Hono context variable types
declare module "hono" {
interface ContextVariableMap {
username?: string;
uuid?: string;
jwtPayload?: { session_id: string; role: string; iat: number; exp: number };
}
}
// --- Environment ---
export interface RegisterEnvironmentRequest {
machine_name?: string;
directory?: string;
branch?: string;
git_repo_url?: string;
max_sessions?: number;
worker_type?: string;
bridge_id?: string;
}
export interface RegisterEnvironmentResponse {
id: string;
secret: string;
status: string;
}
export interface WorkResponse {
id: string;
type: "work";
environment_id: string;
state: string;
data: {
type: "session" | "healthcheck";
id: string;
};
secret: string;
created_at: string;
}
export interface WorkSecretPayload {
version: number;
session_ingress_token: string;
api_base_url: string;
sources: string[];
auth: string[];
use_code_sessions: boolean;
}
// --- Session ---
export interface CreateSessionRequest {
environment_id?: string | null;
title?: string;
events?: unknown[];
source?: string;
permission_mode?: string;
}
export interface SessionResponse {
id: string;
environment_id: string | null;
title: string | null;
status: string;
source: string;
permission_mode: string | null;
worker_epoch: number;
username: string | null;
created_at: number;
updated_at: number;
}
// --- v2 Code Sessions ---
export interface CreateCodeSessionRequest {
title?: string;
source?: string;
permission_mode?: string;
}
export interface BridgeResponse {
api_base_url: string;
worker_epoch: number;
worker_jwt: string;
expires_in: number;
}
// --- Web ---
export interface EnvironmentResponse {
id: string;
machine_name: string | null;
directory: string | null;
branch: string | null;
status: string;
username: string | null;
last_poll_at: number | null;
}
export interface SessionSummaryResponse {
id: string;
title: string | null;
status: string;
username: string | null;
updated_at: number;
}
// --- Web Auth ---
export interface WebLoginRequest {
apiKey: string;
username: string;
}
export interface WebLoginResponse {
token: string;
expires_in: number;
}
export interface WebControlRequest {
type: string;
content?: string;
[key: string]: unknown;
}
// --- Error ---
export interface ErrorResponse {
error: {
type: string;
message: string;
};
}
// --- Event ---
export interface SessionEventPayload {
id: string;
session_id: string;
type: string;
payload: unknown;
direction: "inbound" | "outbound";
seq_num: number;
created_at: number;
}

View File

@@ -0,0 +1,81 @@
/** SDK 消息类型 — 与 CC CLI bridge 模块兼容 */
export interface SDKMessage {
type: string;
content?: unknown;
[key: string]: unknown;
}
export interface UserMessage extends SDKMessage {
type: "user";
content: string;
}
export interface AssistantMessage extends SDKMessage {
type: "assistant";
content: string;
}
export interface PermissionRequest extends SDKMessage {
type: "permission_request";
tool_name: string;
tool_input: unknown;
}
export interface PermissionResponse extends SDKMessage {
type: "permission_response";
approved: boolean;
request_id: string;
}
export interface ControlRequest extends SDKMessage {
type: "control_request";
action: string;
[key: string]: unknown;
}
export type SessionEventType =
| "user"
| "assistant"
| "permission_request"
| "permission_response"
| "control_request"
| "tool_use"
| "tool_result"
| "status"
| "error";
// --- Normalized Event Payloads (SSE contract) ---
export interface NormalizedEventPayload {
content: string;
raw?: unknown;
[key: string]: unknown;
}
export interface UserEventPayload extends NormalizedEventPayload {
content: string;
}
export interface AssistantEventPayload extends NormalizedEventPayload {
content: string;
}
export interface ToolUseEventPayload extends NormalizedEventPayload {
content: string;
tool_name: string;
tool_input: unknown;
}
export interface ToolResultEventPayload extends NormalizedEventPayload {
content: string;
}
export interface PermissionEventPayload extends NormalizedEventPayload {
content: string;
request_id: string;
request: {
subtype: string;
tool_name: string;
tool_input: unknown;
};
}