Gateway: add browser control UI

This commit is contained in:
Peter Steinberger
2025-12-18 22:40:46 +00:00
parent c34da133f6
commit df0c51a63b
21 changed files with 1799 additions and 16 deletions

View File

@@ -26,7 +26,15 @@ import { createDefaultDeps } from "../cli/deps.js";
import { agentCommand } from "../commands/agent.js";
import { getHealthSnapshot, type HealthSummary } from "../commands/health.js";
import { getStatusSummary } from "../commands/status.js";
import { type ClawdisConfig, loadConfig } from "../config/config.js";
import {
type ClawdisConfig,
CONFIG_PATH_CLAWDIS,
loadConfig,
parseConfigJson5,
readConfigFileSnapshot,
validateConfigObject,
writeConfigFile,
} from "../config/config.js";
import {
loadSessionStore,
resolveStorePath,
@@ -90,6 +98,7 @@ import { setHeartbeatsEnabled } from "../web/auto-reply.js";
import { sendMessageWhatsApp } from "../web/outbound.js";
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
import { buildMessageWithAttachments } from "./chat-attachments.js";
import { handleControlUiHttpRequest } from "./control-ui.js";
import {
type ConnectParams,
ErrorCodes,
@@ -105,6 +114,8 @@ import {
validateChatAbortParams,
validateChatHistoryParams,
validateChatSendParams,
validateConfigGetParams,
validateConfigSetParams,
validateConnectParams,
validateCronAddParams,
validateCronListParams,
@@ -183,6 +194,8 @@ type SessionsPatchResult = {
const METHODS = [
"health",
"status",
"config.get",
"config.set",
"voicewake.get",
"voicewake.set",
"sessions.list",
@@ -233,6 +246,27 @@ export type GatewayServer = {
close: () => Promise<void>;
};
export type GatewayServerOptions = {
/**
* Bind address policy for the Gateway WebSocket/HTTP server.
* - loopback: 127.0.0.1
* - lan: 0.0.0.0
* - tailnet: bind only to the Tailscale IPv4 address (100.64.0.0/10)
* - auto: prefer tailnet, else LAN
*/
bind?: import("../config/config.js").BridgeBindMode;
/**
* Advanced override for the bind host, bypassing bind resolution.
* Prefer `bind` unless you really need a specific address.
*/
host?: string;
/**
* If false, do not serve the browser Control UI under /ui/.
* Default: config `gateway.controlUi.enabled` (or true when absent).
*/
controlUiEnabled?: boolean;
};
function isLoopbackAddress(ip: string | undefined): boolean {
if (!ip) return false;
if (ip === "127.0.0.1") return true;
@@ -242,6 +276,21 @@ function isLoopbackAddress(ip: string | undefined): boolean {
return false;
}
function resolveGatewayBindHost(
bind: import("../config/config.js").BridgeBindMode | undefined,
): string | null {
const mode = bind ?? "loopback";
if (mode === "loopback") return "127.0.0.1";
if (mode === "lan") return "0.0.0.0";
if (mode === "tailnet") return pickPrimaryTailnetIPv4() ?? null;
if (mode === "auto") return pickPrimaryTailnetIPv4() ?? "0.0.0.0";
return "127.0.0.1";
}
function isLoopbackHost(host: string): boolean {
return isLoopbackAddress(host);
}
let presenceVersion = 1;
let healthVersion = 1;
let healthCache: HealthSummary | null = null;
@@ -774,9 +823,44 @@ async function refreshHealthSnapshot(_opts?: { probe?: boolean }) {
return healthRefresh;
}
export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
const host = "127.0.0.1";
const httpServer: HttpServer = createHttpServer();
export async function startGatewayServer(
port = 18789,
opts: GatewayServerOptions = {},
): Promise<GatewayServer> {
const cfgForServer = loadConfig();
const bindMode = opts.bind ?? cfgForServer.gateway?.bind ?? "loopback";
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
if (!bindHost) {
throw new Error(
"gateway bind is tailnet, but no tailnet interface was found; refusing to start gateway",
);
}
const controlUiEnabled =
opts.controlUiEnabled ?? cfgForServer.gateway?.controlUi?.enabled ?? true;
if (!isLoopbackHost(bindHost) && !getGatewayToken()) {
throw new Error(
`refusing to bind gateway to ${bindHost}:${port} without CLAWDIS_GATEWAY_TOKEN`,
);
}
const httpServer: HttpServer = createHttpServer((req, res) => {
// Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return;
if (controlUiEnabled) {
if (req.url === "/") {
res.statusCode = 302;
res.setHeader("Location", "/ui/");
res.end();
return;
}
if (handleControlUiHttpRequest(req, res)) return;
}
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
});
let bonjourStop: (() => Promise<void>) | null = null;
let bridge: Awaited<ReturnType<typeof startNodeBridgeServer>> | null = null;
let canvasHost: CanvasHostServer | null = null;
@@ -794,18 +878,18 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
};
httpServer.once("error", onError);
httpServer.once("listening", onListening);
httpServer.listen(port, host);
httpServer.listen(port, bindHost);
});
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "EADDRINUSE") {
throw new GatewayLockError(
`another gateway instance is already listening on ws://${host}:${port}`,
`another gateway instance is already listening on ws://${bindHost}:${port}`,
err,
);
}
throw new GatewayLockError(
`failed to bind gateway socket on ws://${host}:${port}: ${String(err)}`,
`failed to bind gateway socket on ws://${bindHost}:${port}: ${String(err)}`,
err,
);
}
@@ -827,6 +911,7 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
{ sessionKey: string; clientRunId: string }
>();
const chatRunBuffers = new Map<string, string>();
const chatDeltaSentAt = new Map<string, number>();
const chatAbortControllers = new Map<
string,
{ controller: AbortController; sessionId: string; sessionKey: string }
@@ -1171,6 +1256,63 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
const snap = await refreshHealthSnapshot({ probe: false });
return { ok: true, payloadJSON: JSON.stringify(snap) };
}
case "config.get": {
const params = parseParams();
if (!validateConfigGetParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid config.get params: ${formatValidationErrors(validateConfigGetParams.errors)}`,
},
};
}
const snapshot = await readConfigFileSnapshot();
return { ok: true, payloadJSON: JSON.stringify(snapshot) };
}
case "config.set": {
const params = parseParams();
if (!validateConfigSetParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid config.set params: ${formatValidationErrors(validateConfigSetParams.errors)}`,
},
};
}
const raw = String((params as { raw?: unknown }).raw ?? "");
const parsedRes = parseConfigJson5(raw);
if (!parsedRes.ok) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: parsedRes.error,
},
};
}
const validated = validateConfigObject(parsedRes.parsed);
if (!validated.ok) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "invalid config",
details: { issues: validated.issues },
},
};
}
await writeConfigFile(validated.config);
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
path: CONFIG_PATH_CLAWDIS,
config: validated.config,
}),
};
}
case "sessions.list": {
const params = parseParams();
if (!validateSessionsListParams(params)) {
@@ -1366,6 +1508,7 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
active.controller.abort();
chatAbortControllers.delete(runId);
chatRunBuffers.delete(runId);
chatDeltaSentAt.delete(runId);
const current = chatRunSessions.get(active.sessionId);
if (
current?.clientRunId === runId &&
@@ -1970,6 +2113,23 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
};
if (evt.stream === "assistant" && typeof evt.data?.text === "string") {
chatRunBuffers.set(clientRunId, evt.data.text);
const now = Date.now();
const last = chatDeltaSentAt.get(clientRunId) ?? 0;
// Throttle UI delta events so slow clients don't accumulate unbounded buffers.
if (now - last >= 150) {
chatDeltaSentAt.set(clientRunId, now);
const payload = {
...base,
state: "delta" as const,
message: {
role: "assistant",
content: [{ type: "text", text: evt.data.text }],
timestamp: now,
},
};
broadcast("chat", payload, { dropIfSlow: true });
bridgeSendToSession(sessionKey, "chat", payload);
}
} else if (
evt.stream === "job" &&
typeof evt.data?.state === "string" &&
@@ -1977,6 +2137,7 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
) {
const text = chatRunBuffers.get(clientRunId)?.trim() ?? "";
chatRunBuffers.delete(clientRunId);
chatDeltaSentAt.delete(clientRunId);
if (evt.data.state === "done") {
const payload = {
...base,
@@ -2457,6 +2618,7 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
active.controller.abort();
chatAbortControllers.delete(runId);
chatRunBuffers.delete(runId);
chatDeltaSentAt.delete(runId);
const current = chatRunSessions.get(active.sessionId);
if (
current?.clientRunId === runId &&
@@ -2795,6 +2957,69 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
respond(true, status, undefined);
break;
}
case "config.get": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateConfigGetParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid config.get params: ${formatValidationErrors(validateConfigGetParams.errors)}`,
),
);
break;
}
const snapshot = await readConfigFileSnapshot();
respond(true, snapshot, undefined);
break;
}
case "config.set": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateConfigSetParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid config.set params: ${formatValidationErrors(validateConfigSetParams.errors)}`,
),
);
break;
}
const raw = String((params as { raw?: unknown }).raw ?? "");
const parsedRes = parseConfigJson5(raw);
if (!parsedRes.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error),
);
break;
}
const validated = validateConfigObject(parsedRes.parsed);
if (!validated.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "invalid config", {
details: { issues: validated.issues },
}),
);
break;
}
await writeConfigFile(validated.config);
respond(
true,
{
ok: true,
path: CONFIG_PATH_CLAWDIS,
config: validated.config,
},
undefined,
);
break;
}
case "sessions.list": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateSessionsListParams(params)) {
@@ -3814,7 +4039,7 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
});
defaultRuntime.log(
`gateway listening on ws://127.0.0.1:${port} (PID ${process.pid})`,
`gateway listening on ws://${bindHost}:${port} (PID ${process.pid})`,
);
defaultRuntime.log(`gateway log file: ${getResolvedLoggerSettings().file}`);