Webchat: auto-start server and simplify config

This commit is contained in:
Peter Steinberger
2025-12-08 13:12:20 +00:00
parent d833de793d
commit 17a6d716ad
6 changed files with 56 additions and 20 deletions

View File

@@ -23,8 +23,8 @@ This flow lets the macOS app act as a full remote control for a Clawdis relay ru
4) Health checks and Web Chat will now run through this SSH tunnel automatically. 4) Health checks and Web Chat will now run through this SSH tunnel automatically.
## Web Chat over SSH ## Web Chat over SSH
- The relay hosts a loopback-only HTTP server (`clawdis webchat --port <port>`; default 18788). - The relay hosts a loopback-only HTTP server (default 18788, see `webchat.port`).
- The mac app forwards `127.0.0.1:<port>` over SSH (`ssh -L <ephemeral>:127.0.0.1:<port>`), loads `/webchat/info`, and serves the Web Chat UI in-app. - The mac app forwards `127.0.0.1:<port>` over SSH (`ssh -L <ephemeral>:127.0.0.1:<port>`), then loads `/webchat/?session=<key>` in-app.
- Keep the feature enabled in *Settings → Config → Web chat*. Disable it to hide the menu entry entirely. - Keep the feature enabled in *Settings → Config → Web chat*. Disable it to hide the menu entry entirely.
## Permissions ## Permissions
@@ -38,7 +38,7 @@ This flow lets the macOS app act as a full remote control for a Clawdis relay ru
## Troubleshooting ## Troubleshooting
- **exit 127 / not found**: `clawdis` isnt on PATH for non-login shells. Add it to `/etc/paths`, your shell rc, or symlink into `/usr/local/bin`/`/opt/homebrew/bin`. - **exit 127 / not found**: `clawdis` isnt on PATH for non-login shells. Add it to `/etc/paths`, your shell rc, or symlink into `/usr/local/bin`/`/opt/homebrew/bin`.
- **Health probe failed**: check SSH reachability, PATH, and that Baileys is logged in (`clawdis status --json`). - **Health probe failed**: check SSH reachability, PATH, and that Baileys is logged in (`clawdis status --json`).
- **Web Chat stuck**: confirm the remote webchat server is running (`clawdis webchat --json`) and the port matches *Settings → Config*. - **Web Chat stuck**: confirm the relay is running on the remote host and `webchat.enabled` is true; ensure the forwarded port matches *Settings → Config*.
- **Voice Wake**: trigger phrases are forwarded automatically in remote mode; no separate forwarder is needed. - **Voice Wake**: trigger phrases are forwarded automatically in remote mode; no separate forwarder is needed.
## Notification sounds ## Notification sounds

View File

@@ -3,19 +3,18 @@
Updated: 2025-12-08 Updated: 2025-12-08
## What shipped ## What shipped
- A lightweight HTTP server now lives inside the Node relay (`clawdis webchat --port 18788`). - The relay now starts a loopback-only web chat server automatically (default port **18788**, configurable via `webchat.port`).
- It binds to **127.0.0.1** only and serves: - Endpoints:
- `GET /webchat/info?session=<key>``{port, token, sessionId, initialMessages, basePath}` plus history from the relays session store. - `GET /webchat/info?session=<key>``{port, sessionId, initialMessages, basePath}` plus history from the relays session store.
- `GET /webchat/*` → static Web Chat assets. - `GET /webchat/*` → static Web Chat assets.
- `POST /webchat/rpc` → runs `clawdis agent --json` and returns `{ ok, payloads?, error? }`. - `POST /webchat/rpc` → runs `clawdis agent --json` and returns `{ ok, payloads?, error? }`.
- The macOS app embeds this UI in a WKWebView. In **remote mode** it first opens an SSH tunnel (`ssh -L <local>:127.0.0.1:<port>`) to the remote host, then loads `/webchat/info` through that tunnel. - The macOS app simply loads `http://127.0.0.1:<port>/webchat/?session=<key>` (or the SSH-forwarded port in remote mode). No Swift bridge is used for sends; all chat traffic stays inside the Node relay.
- Initial messages are preloaded from the relays session store, so remote sessions appear immediately. - Initial messages are fetched from `/webchat/info`, so history appears immediately.
- Sending now goes over the HTTP `/webchat/rpc` endpoint (no more AgentRPC fallback). - Enable/disable via `webchat.enabled` (default **true**); set the port with `webchat.port`.
- Feature flag + port live in *Settings → Config → Web chat*. When disabled, the “Open Chat” menu entry is hidden.
## Security ## Security
- Loopback only; remote access requires SSH port-forwarding. - Loopback only; remote access requires SSH port-forwarding.
- Optional bearer token support is wired; tokens are returned by `/webchat/info` and accepted by `/webchat/rpc`. - No bearer token; the trust model is “local machine or your SSH tunnel”.
## Failure handling ## Failure handling
- Bootstrap errors show in-app (“Web chat failed to connect …”) instead of hanging. - Bootstrap errors show in-app (“Web chat failed to connect …”) instead of hanging.
@@ -24,7 +23,7 @@ Updated: 2025-12-08
## Dev notes ## Dev notes
- Static assets stay in `apps/macos/Sources/Clawdis/Resources/WebChat`; the server reads them directly. - Static assets stay in `apps/macos/Sources/Clawdis/Resources/WebChat`; the server reads them directly.
- Server code: `src/webchat/server.ts`. - Server code: `src/webchat/server.ts`.
- CLI entrypoint: `clawdis webchat --json [--port N]`. - CLI entrypoint (optional): `clawdis webchat --json [--port N]` to query/start manually.
- Mac glue: `WebChatWindow.swift` (bootstrap + tunnel) and `WebChatTunnel` (SSH -L). - Mac glue: `WebChatWindow.swift` (bootstrap + tunnel) and `WebChatTunnel` (SSH -L).
## TODO / nice-to-haves ## TODO / nice-to-haves

View File

@@ -7,7 +7,8 @@ const monitorWebProvider = vi.fn();
const logWebSelfId = vi.fn(); const logWebSelfId = vi.fn();
const waitForever = vi.fn(); const waitForever = vi.fn();
const monitorTelegramProvider = vi.fn(); const monitorTelegramProvider = vi.fn();
const startWebChatServer = vi.fn(async () => ({ port: 18788, token: null })); const startWebChatServer = vi.fn(async () => ({ port: 18788 }));
const ensureWebChatServerFromConfig = vi.fn(async () => ({ port: 18788 }));
const runtime = { const runtime = {
log: vi.fn(), log: vi.fn(),
@@ -29,6 +30,7 @@ vi.mock("../telegram/monitor.js", () => ({
})); }));
vi.mock("../webchat/server.js", () => ({ vi.mock("../webchat/server.js", () => ({
startWebChatServer, startWebChatServer,
ensureWebChatServerFromConfig,
getWebChatServer: () => null, getWebChatServer: () => null,
})); }));
vi.mock("./deps.js", () => ({ vi.mock("./deps.js", () => ({
@@ -104,7 +106,7 @@ describe("cli program", () => {
await program.parseAsync(["webchat", "--json"], { from: "user" }); await program.parseAsync(["webchat", "--json"], { from: "user" });
expect(startWebChatServer).toHaveBeenCalled(); expect(startWebChatServer).toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith( expect(runtime.log).toHaveBeenCalledWith(
JSON.stringify({ port: 18788, token: null, basePath: "/webchat/", host: "127.0.0.1" }), JSON.stringify({ port: 18788, basePath: "/webchat/", host: "127.0.0.1" }),
); );
}); });
}); });

View File

@@ -17,7 +17,11 @@ import {
setHeartbeatsEnabled, setHeartbeatsEnabled,
type WebMonitorTuning, type WebMonitorTuning,
} from "../provider-web.js"; } from "../provider-web.js";
import { startWebChatServer, getWebChatServer } from "../webchat/server.js"; import {
startWebChatServer,
getWebChatServer,
ensureWebChatServerFromConfig,
} from "../webchat/server.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import { import {
@@ -509,6 +513,16 @@ Examples:
), ),
); );
try { try {
// Start loopback web chat server unless disabled.
const webchatServer = await ensureWebChatServerFromConfig();
if (webchatServer) {
defaultRuntime.log(
info(
`webchat listening on http://127.0.0.1:${webchatServer.port}/webchat/`,
),
);
}
await monitorWebProvider( await monitorWebProvider(
Boolean(opts.verbose), Boolean(opts.verbose),
undefined, undefined,
@@ -748,7 +762,6 @@ Shows token usage per session when the agent reports it; set inbound.reply.agent
const server = await startWebChatServer(port); const server = await startWebChatServer(port);
const payload = { const payload = {
port: server.port, port: server.port,
token: server.token ?? null,
basePath: "/webchat/", basePath: "/webchat/",
host: "127.0.0.1", host: "127.0.0.1",
}; };

View File

@@ -44,6 +44,11 @@ export type WebConfig = {
reconnect?: WebReconnectConfig; reconnect?: WebReconnectConfig;
}; };
export type WebChatConfig = {
enabled?: boolean;
port?: number;
};
export type TelegramConfig = { export type TelegramConfig = {
botToken?: string; botToken?: string;
requireMention?: boolean; requireMention?: boolean;
@@ -101,6 +106,7 @@ export type WarelayConfig = {
}; };
web?: WebConfig; web?: WebConfig;
telegram?: TelegramConfig; telegram?: TelegramConfig;
webchat?: WebChatConfig;
}; };
// New branding path (preferred) // New branding path (preferred)
@@ -226,6 +232,12 @@ const WarelaySchema = z.object({
.optional(), .optional(),
}) })
.optional(), .optional(),
webchat: z
.object({
enabled: z.boolean().optional(),
port: z.number().int().positive().optional(),
})
.optional(),
telegram: z telegram: z
.object({ .object({
botToken: z.string().optional(), botToken: z.string().optional(),

View File

@@ -20,7 +20,6 @@ const WEBCHAT_DEFAULT_PORT = 18788;
type WebChatServerState = { type WebChatServerState = {
server: http.Server; server: http.Server;
port: number; port: number;
token?: string;
}; };
let state: WebChatServerState | null = null; let state: WebChatServerState | null = null;
@@ -119,7 +118,7 @@ function notFound(res: http.ServerResponse) {
res.end("Not Found"); res.end("Not Found");
} }
export async function startWebChatServer(port = WEBCHAT_DEFAULT_PORT, token?: string) { export async function startWebChatServer(port = WEBCHAT_DEFAULT_PORT) {
if (state) return state; if (state) return state;
const root = resolveWebRoot(); const root = resolveWebRoot();
@@ -147,7 +146,6 @@ export async function startWebChatServer(port = WEBCHAT_DEFAULT_PORT, token?: st
res.end( res.end(
JSON.stringify({ JSON.stringify({
port, port,
token: token ?? null,
sessionKey, sessionKey,
storePath, storePath,
sessionId, sessionId,
@@ -202,11 +200,23 @@ export async function startWebChatServer(port = WEBCHAT_DEFAULT_PORT, token?: st
server.listen(port, "127.0.0.1", () => resolve()); server.listen(port, "127.0.0.1", () => resolve());
}); });
state = { server, port, token }; state = { server, port };
logDebug(info(`webchat server listening on 127.0.0.1:${port}`)); logDebug(info(`webchat server listening on 127.0.0.1:${port}`));
return state; return state;
} }
export async function ensureWebChatServerFromConfig() {
const cfg = loadConfig();
if (cfg.webchat?.enabled === false) return null;
const port = cfg.webchat?.port ?? WEBCHAT_DEFAULT_PORT;
try {
return await startWebChatServer(port);
} catch (err) {
logDebug(`webchat server failed to start: ${String(err)}`);
throw err;
}
}
export function getWebChatServer(): WebChatServerState | null { export function getWebChatServer(): WebChatServerState | null {
return state; return state;
} }