diff --git a/rust/Cargo.lock b/rust/Cargo.lock index c7626df..29c252d 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -3973,7 +3973,7 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "smooai-smooth-operator" -version = "1.4.0" +version = "1.7.0" dependencies = [ "anyhow", "async-trait", @@ -4005,7 +4005,7 @@ dependencies = [ [[package]] name = "smooai-smooth-operator-adapter-backplane-nats" -version = "1.4.0" +version = "1.7.0" dependencies = [ "anyhow", "async-nats", @@ -4021,7 +4021,7 @@ dependencies = [ [[package]] name = "smooai-smooth-operator-adapter-backplane-redis" -version = "1.4.0" +version = "1.7.0" dependencies = [ "anyhow", "async-trait", @@ -4037,7 +4037,7 @@ dependencies = [ [[package]] name = "smooai-smooth-operator-adapter-dynamodb" -version = "1.4.0" +version = "1.7.0" dependencies = [ "anyhow", "async-trait", @@ -4058,7 +4058,7 @@ dependencies = [ [[package]] name = "smooai-smooth-operator-adapter-memory" -version = "1.4.0" +version = "1.7.0" dependencies = [ "anyhow", "async-trait", @@ -4071,7 +4071,7 @@ dependencies = [ [[package]] name = "smooai-smooth-operator-adapter-postgres" -version = "1.4.0" +version = "1.7.0" dependencies = [ "anyhow", "async-trait", @@ -4157,7 +4157,7 @@ dependencies = [ [[package]] name = "smooai-smooth-operator-ingestion" -version = "1.4.0" +version = "1.7.0" dependencies = [ "anyhow", "async-trait", @@ -4204,7 +4204,7 @@ dependencies = [ [[package]] name = "smooai-smooth-operator-server" -version = "1.4.0" +version = "1.7.0" dependencies = [ "anyhow", "async-trait", diff --git a/rust/smooth-operator-server/assets/README.md b/rust/smooth-operator-server/assets/README.md new file mode 100644 index 0000000..1cae494 --- /dev/null +++ b/rust/smooth-operator-server/assets/README.md @@ -0,0 +1,27 @@ +# Vendored widget assets (local deployment flavor) + +`chat-widget.iife.js` is the prebuilt standalone widget bundle from the +published **`@smooai/smooth-operator`** npm package (the `` +web component, `dist/widget/chat-widget.iife.js`). It is vendored here so the +local deployment flavor can serve the official widget **offline**, with no Node +build step. + +`widget-index.html` is the host page the local flavor serves at `/`; it loads +the bundle and points a `` at this server's own `/ws`, with +the auth token injected into the `?token=` slot (same-origin, loopback). + +These are served **only** when a host opts in via +`LocalServerBuilder::serve_widget(...)` — the K8s/Lambda flavors never mount the +widget routes. + +## Keeping it current + +Pinned to `@smooai/smooth-operator@1.2.0`. To refresh after a widget release: + +```sh +npm pack @smooai/smooth-operator +tar xzf smooai-smooth-operator-*.tgz -C /tmp package/dist/widget/chat-widget.iife.js +cp /tmp/package/dist/widget/chat-widget.iife.js chat-widget.iife.js +``` + +(A CI step that does this on widget release would keep the two in lockstep.) diff --git a/rust/smooth-operator-server/assets/chat-widget.iife.js b/rust/smooth-operator-server/assets/chat-widget.iife.js new file mode 100644 index 0000000..12b4b20 --- /dev/null +++ b/rust/smooth-operator-server/assets/chat-widget.iife.js @@ -0,0 +1,1301 @@ +var SmoothAgentChat = (function(exports) { + Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); + //#region src/widget/config.ts + /** Resolve a partial config against the built-in defaults. */ + function resolveConfig(config) { + const theme = config.theme ?? {}; + const primary = theme.primary ?? "#00a6a6"; + const primaryText = theme.primaryText ?? "#f8fafc"; + return { + endpoint: config.endpoint, + mode: config.mode ?? "popover", + agentId: config.agentId, + agentName: config.agentName ?? "Assistant", + userName: config.userName, + userEmail: config.userEmail, + placeholder: config.placeholder ?? "Type a message…", + greeting: config.greeting ?? "Hi! How can I help you today?", + connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.", + startOpen: config.startOpen ?? false, + theme: { + text: theme.text ?? "#f8fafc", + background: theme.background ?? "#040d30", + primary, + primaryText, + assistantBubble: theme.assistantBubble ?? "#06134b", + assistantBubbleText: theme.assistantBubbleText ?? "#f8fafc", + userBubble: theme.userBubble ?? primary, + userBubbleText: theme.userBubbleText ?? primaryText, + border: theme.border ?? "#0a1f7a" + } + }; + } + //#endregion + //#region src/transport.ts + const WS_CONNECTING = 0; + const WS_OPEN = 1; + const WS_CLOSING = 2; + /** Default connect timeout (ms) for the WebSocket transport. */ + const DEFAULT_CONNECT_TIMEOUT = 3e4; + /** + * Default transport backed by a `WebSocket`-like object. By default it uses the + * global `WebSocket`; pass a `factory` to inject one (e.g. the `ws` package on + * Node, or a mock in tests). + */ + var WebSocketTransport = class { + socket = null; + url; + factory; + connectTimeout; + messageHandlers = /* @__PURE__ */ new Set(); + closeHandlers = /* @__PURE__ */ new Set(); + errorHandlers = /* @__PURE__ */ new Set(); + constructor(url, factory, connectTimeout = DEFAULT_CONNECT_TIMEOUT) { + this.url = url; + this.connectTimeout = connectTimeout; + if (factory) this.factory = factory; + else { + const G = globalThis; + if (!G.WebSocket) throw new Error("No global WebSocket available; pass a WebSocketFactory to WebSocketTransport."); + const Ctor = G.WebSocket; + this.factory = (u) => new Ctor(u); + } + } + get state() { + if (!this.socket) return "closed"; + switch (this.socket.readyState) { + case WS_CONNECTING: return "connecting"; + case WS_OPEN: return "open"; + case WS_CLOSING: return "closing"; + default: return "closed"; + } + } + connect() { + if (this.socket && this.socket.readyState === WS_OPEN) return Promise.resolve(); + if (this.socket && this.socket.readyState !== WS_OPEN) { + const stale = this.socket; + this.socket = null; + try { + stale.close(); + } catch {} + } + return new Promise((resolve, reject) => { + const socket = this.factory(this.url); + this.socket = socket; + let settled = false; + const timer = this.connectTimeout > 0 ? setTimeout(() => { + if (settled) return; + settled = true; + if (this.socket === socket) this.socket = null; + try { + socket.close(); + } catch {} + reject(/* @__PURE__ */ new Error(`WebSocket connect to ${this.url} timed out after ${this.connectTimeout}ms`)); + }, this.connectTimeout) : void 0; + socket.addEventListener("open", () => { + if (this.socket !== socket) return; + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve(); + }); + socket.addEventListener("error", (ev) => { + if (this.socket !== socket) return; + for (const h of this.errorHandlers) h(ev); + if (!settled && this.state !== "open") { + settled = true; + if (timer) clearTimeout(timer); + if (this.socket === socket) this.socket = null; + try { + socket.close(); + } catch {} + reject(ev instanceof Error ? ev : /* @__PURE__ */ new Error("WebSocket connection error")); + } + }); + socket.addEventListener("close", (ev) => { + if (this.socket !== socket) return; + if (timer) clearTimeout(timer); + for (const h of this.closeHandlers) h({ + code: ev.code, + reason: ev.reason + }); + }); + socket.addEventListener("message", (ev) => { + if (this.socket !== socket) return; + const data = typeof ev.data === "string" ? ev.data : String(ev.data); + for (const h of this.messageHandlers) h(data); + }); + }); + } + send(data) { + if (!this.socket || this.socket.readyState !== WS_OPEN) throw new Error(`Cannot send: transport is "${this.state}"`); + this.socket.send(data); + } + close(code, reason) { + this.socket?.close(code, reason); + } + onMessage(handler) { + this.messageHandlers.add(handler); + return () => this.messageHandlers.delete(handler); + } + onClose(handler) { + this.closeHandlers.add(handler); + return () => this.closeHandlers.delete(handler); + } + onError(handler) { + this.errorHandlers.add(handler); + return () => this.errorHandlers.delete(handler); + } + }; + //#endregion + //#region src/types.ts + /** Every server→client `type` discriminator value. */ + const EVENT_TYPES = [ + "immediate_response", + "eventual_response", + "stream_chunk", + "stream_token", + "keepalive", + "write_confirmation_required", + "otp_verification_required", + "otp_sent", + "otp_verified", + "otp_invalid", + "error", + "pong" + ]; + /** True if `frame` looks like any server event (has a known `type` discriminator). */ + function isServerEvent(frame) { + return typeof frame === "object" && frame !== null && "type" in frame && typeof frame.type === "string" && EVENT_TYPES.includes(frame.type); + } + //#endregion + //#region src/client.ts + /** + * SmoothAgentClient — a minimal, idiomatic, transport-agnostic client for the + * smooth-operator WebSocket protocol. + * + * Design goals + * ------------ + * - **Transport-agnostic.** The client never touches a real socket directly; it + * talks to an injectable {@link Transport}. The default ({@link WebSocketTransport}) + * uses the global `WebSocket`, but tests inject a mock and Node can inject `ws`. + * - **Request/response correlation by `requestId`.** Every action gets a generated + * `requestId`; the client routes incoming events back to the originating call. + * - **Streaming as an async iterator.** `sendMessage` returns a {@link MessageTurn} + * that is both awaitable (resolves with the terminal `eventual_response`) and + * async-iterable (yields each `stream_token` / `stream_chunk` / HITL event in + * order). This models the `stream_token`/`stream_chunk` → `eventual_response` + * flow without forcing a callback style on the caller. + * - **No live server required.** Correctness is fully unit-testable with a mock + * transport (see `test/client.test.ts`). + */ + /** A timeout that yields no terminal event. */ + var RequestTimeoutError = class extends Error { + constructor(requestId, ms) { + super(`Request ${requestId} timed out after ${ms}ms`); + this.name = "RequestTimeoutError"; + } + }; + /** + * A streaming turn that received no terminal `eventual_response` / `error` within the + * configured {@link SmoothAgentClientOptions.turnTimeout}. The turn rejects with this + * and its async iteration throws it, so a stuck server can never hang the caller. + */ + var TurnTimeoutError = class extends Error { + requestId; + constructor(requestId, ms) { + super(`Turn ${requestId} timed out after ${ms}ms without a terminal response`); + this.name = "TurnTimeoutError"; + this.requestId = requestId; + } + }; + /** A protocol-level error event surfaced as a throwable. */ + var ProtocolError = class extends Error { + code; + requestId; + constructor(code, message, requestId) { + super(message); + this.name = "ProtocolError"; + this.code = code; + this.requestId = requestId; + } + }; + /** + * A streaming message turn. Await it for the terminal {@link EventualResponse}, + * or async-iterate it to receive every intermediate event in arrival order. + * + * ```ts + * const turn = client.sendMessage({ sessionId, message: 'hi' }); + * for await (const ev of turn) { + * if (ev.type === 'stream_token') process.stdout.write(ev.token ?? ''); + * } + * const final = await turn; // EventualResponse + * ``` + */ + var MessageTurn = class { + /** The requestId this turn is correlated on. */ + requestId; + queue = []; + waiter = null; + done = false; + finalEvent = null; + error = null; + settled; + settle; + fail; + onClose; + timeoutTimer; + constructor(requestId, onClose, turnTimeout = 0) { + this.requestId = requestId; + this.onClose = onClose; + this.settled = new Promise((resolve, reject) => { + this.settle = resolve; + this.fail = reject; + }); + this.settled.catch(() => {}); + if (turnTimeout > 0) this.timeoutTimer = setTimeout(() => { + this.finish(null, new TurnTimeoutError(this.requestId, turnTimeout)); + }, turnTimeout); + } + /** Feed an event into the turn (called by the client's dispatcher). */ + push(event) { + if (this.done) return; + if (event.type === "error") { + const code = event.data?.error?.code ?? "INTERNAL_ERROR"; + const message = event.data?.error?.message ?? "Unknown protocol error"; + this.deliver(event); + this.finish(null, new ProtocolError(code, message, this.requestId)); + return; + } + this.deliver(event); + if (event.type === "eventual_response") this.finish(event, null); + } + /** Force-close the turn (e.g. on disconnect) with an error. */ + abort(err) { + if (this.done) return; + this.finish(null, err); + } + deliver(event) { + if (this.waiter) { + const w = this.waiter; + this.waiter = null; + w.resolve({ + value: event, + done: false + }); + } else this.queue.push(event); + } + finish(final, err) { + if (this.done) return; + this.done = true; + this.finalEvent = final; + this.error = err; + if (this.timeoutTimer) { + clearTimeout(this.timeoutTimer); + this.timeoutTimer = void 0; + } + this.onClose(); + if (err) this.fail(err); + else if (final) this.settle(final); + if (this.waiter) { + const w = this.waiter; + this.waiter = null; + if (err) w.reject(err); + else w.resolve({ + value: void 0, + done: true + }); + } + } + [Symbol.asyncIterator]() { + return { next: () => { + if (this.queue.length > 0) return Promise.resolve({ + value: this.queue.shift(), + done: false + }); + if (this.done) { + if (this.error) return Promise.reject(this.error); + return Promise.resolve({ + value: void 0, + done: true + }); + } + return new Promise((resolve, reject) => { + this.waiter = { + resolve, + reject + }; + }); + } }; + } + then(onfulfilled, onrejected) { + return this.settled.then(onfulfilled, onrejected); + } + }; + var SmoothAgentClient = class { + transport; + generateRequestId; + requestTimeout; + turnTimeout; + /** requestId → single-response waiter (create_session, get_session, ping, …). */ + pending = /* @__PURE__ */ new Map(); + /** requestId → active streaming turn (send_message, and HITL resumes). */ + turns = /* @__PURE__ */ new Map(); + /** Unsolicited-event listeners (keepalive, server-push). */ + listeners = /* @__PURE__ */ new Set(); + unsubscribe = []; + constructor(options) { + this.transport = options.transport ?? new WebSocketTransport(options.url, options.webSocketFactory); + this.requestTimeout = options.requestTimeout ?? 3e4; + this.turnTimeout = options.turnTimeout ?? 12e4; + this.generateRequestId = options.generateRequestId ?? (() => `req-${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`); + this.unsubscribe.push(this.transport.onMessage((data) => this.handleFrame(data))); + this.unsubscribe.push(this.transport.onClose(() => this.failAll(/* @__PURE__ */ new Error("Transport closed")))); + } + /** Open the underlying transport. */ + async connect() { + await this.transport.connect(); + } + /** Close the transport and reject all in-flight work. */ + disconnect(reason = "client disconnect") { + this.failAll(new Error(reason)); + for (const u of this.unsubscribe) u(); + this.unsubscribe = []; + this.transport.close(1e3, reason); + } + /** Subscribe to unsolicited / uncorrelated server events (e.g. keepalive). */ + onEvent(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + /** Start a new conversation session. Resolves with the session descriptor. */ + async createConversationSession(req) { + return extractImmediateData(await this.request({ + action: "create_conversation_session", + ...req + })); + } + /** Fetch a session snapshot by ID. */ + async getSession(req) { + return extractImmediateData(await this.request({ + action: "get_session", + ...req + })); + } + /** Fetch a page of conversation messages. */ + async getMessages(req) { + return extractImmediateData(await this.request({ + action: "get_conversation_messages", + ...req + })); + } + /** Keepalive ping. Resolves with the server timestamp from the `pong` event. */ + async ping() { + const event = await this.request({ action: "ping" }); + if (event.type === "pong") return event.timestamp ?? event.data?.timestamp ?? Date.now(); + return Date.now(); + } + /** + * Submit a user message and return a {@link MessageTurn}: await it for the + * terminal `eventual_response`, or async-iterate it for the streaming events. + */ + sendMessage(req) { + const requestId = this.generateRequestId(); + const turn = new MessageTurn(requestId, () => this.turns.delete(requestId), this.turnTimeout); + this.turns.set(requestId, turn); + try { + this.transport.send(JSON.stringify({ + action: "send_message", + requestId, + ...req + })); + } catch (err) { + this.turns.delete(requestId); + turn.abort(err); + } + return turn; + } + /** + * Approve or reject a pending tool write, resuming the paused turn identified + * by `requestId`. The resumed streaming events flow back into the original + * {@link MessageTurn} for that `requestId`. + */ + confirmToolAction(req) { + this.transport.send(JSON.stringify({ + action: "confirm_tool_action", + ...req + })); + } + /** + * Submit an OTP code, resuming the paused turn identified by `requestId`. + * The resumed streaming events flow back into the original {@link MessageTurn}. + */ + verifyOtp(req) { + this.transport.send(JSON.stringify({ + action: "verify_otp", + ...req + })); + } + /** Send an action that expects a single correlated response event. */ + request(action) { + const requestId = action.requestId ?? this.generateRequestId(); + const frame = { + ...action, + requestId + }; + return new Promise((resolve, reject) => { + const timer = this.requestTimeout > 0 ? setTimeout(() => { + this.pending.delete(requestId); + reject(new RequestTimeoutError(requestId, this.requestTimeout)); + }, this.requestTimeout) : void 0; + this.pending.set(requestId, { + resolve, + reject, + timer + }); + try { + this.transport.send(JSON.stringify(frame)); + } catch (err) { + if (timer) clearTimeout(timer); + this.pending.delete(requestId); + reject(err); + } + }); + } + /** Parse and route an incoming frame to the right consumer. */ + handleFrame(data) { + let frame; + try { + frame = JSON.parse(data); + } catch { + return; + } + if (!isServerEvent(frame)) return; + const event = frame; + const requestId = event.requestId; + if (requestId && this.turns.has(requestId)) { + this.turns.get(requestId).push(event); + return; + } + if (requestId && this.pending.has(requestId)) { + const pending = this.pending.get(requestId); + this.pending.delete(requestId); + if (pending.timer) clearTimeout(pending.timer); + if (event.type === "error") { + const code = event.data?.error?.code ?? "INTERNAL_ERROR"; + const message = event.data?.error?.message ?? "Unknown protocol error"; + pending.reject(new ProtocolError(code, message, requestId)); + } else pending.resolve(event); + return; + } + for (const l of this.listeners) l(event); + } + failAll(err) { + for (const [, p] of this.pending) { + if (p.timer) clearTimeout(p.timer); + p.reject(err); + } + this.pending.clear(); + for (const [, turn] of this.turns) turn.abort(err); + this.turns.clear(); + } + }; + /** Pull the typed `data` payload out of an `immediate_response` event. */ + function extractImmediateData(event) { + if (event.type === "immediate_response") return event.data; + if ("data" in event && event.data && typeof event.data === "object") return event.data; + throw new ProtocolError("UNEXPECTED_EVENT", `Expected immediate_response, got "${event.type}"`, event.requestId); + } + //#endregion + //#region src/widget/conversation.ts + /** + * ConversationController — the bridge between the widget UI and the + * `@smooai/smooth-operator` protocol client. + * + * This is the piece that was rewired: the original smooai widget spoke to + * `@smooai/realtime`; here every protocol action goes through {@link SmoothAgentClient}. + * The wire shapes are identical (the protocol was lifted from `@smooai/realtime`), + * so the swap is purely at the client-library boundary. + * + * Flow: + * 1. `connect()` → opens the WebSocket transport and `create_conversation_session`. + * 2. `send(text)` → `send_message`, streaming `stream_token` deltas into the + * in-progress assistant message, then the terminal + * `eventual_response`. + * + * The controller is UI-agnostic: it emits typed events and the view renders them. + */ + /** Pull the final assistant text out of an `eventual_response` data payload. */ + function extractFinalText(response) { + if (!response || typeof response !== "object") return null; + const r = response; + if (Array.isArray(r.responseParts)) return r.responseParts.filter((p) => typeof p === "string").join("\n\n"); + return null; + } + /** + * Pull the grounding {@link Citation}s out of a terminal `eventual_response`. + * + * The protocol client types these (`eventual_response.data.data.citations`), + * but they're optional and back-compatible — absent when the turn used no + * knowledge sources. We read them defensively (tolerating their total absence, + * non-array shapes, and missing fields) so a server that doesn't emit them, or + * an older client, can't break rendering. Each citation always carries + * `id`/`title`/`snippet`/`score`; `url` is present only for web-sourced docs. + */ + function extractCitations(inner) { + if (!inner || typeof inner !== "object") return []; + const raw = inner.citations; + if (!Array.isArray(raw)) return []; + const out = []; + for (const c of raw) { + if (!c || typeof c !== "object") continue; + const obj = c; + const id = typeof obj.id === "string" ? obj.id : ""; + const title = typeof obj.title === "string" ? obj.title : id || "Source"; + const snippet = typeof obj.snippet === "string" ? obj.snippet : ""; + const url = typeof obj.url === "string" && obj.url ? obj.url : void 0; + const score = typeof obj.score === "number" ? obj.score : 0; + out.push({ + id, + title, + snippet, + score, + url + }); + } + return out; + } + var ConversationController = class { + config; + events; + client = null; + sessionId = null; + messages = []; + status = "idle"; + seq = 0; + constructor(config, events) { + this.config = config; + this.events = events; + } + get connectionStatus() { + return this.status; + } + nextId(prefix) { + this.seq += 1; + return `${prefix}-${this.seq}-${Date.now().toString(36)}`; + } + setStatus(status, detail) { + this.status = status; + this.events.onStatus(status, detail); + } + emitMessages() { + this.events.onMessages(this.messages.map((m) => ({ ...m }))); + } + /** Open the transport and create a conversation session. Idempotent. */ + async connect() { + if (this.status === "connecting" || this.status === "ready") return; + this.setStatus("connecting"); + try { + this.client = new SmoothAgentClient({ url: this.config.endpoint }); + await this.client.connect(); + const session = await this.client.createConversationSession({ + agentId: this.config.agentId, + userName: this.config.userName, + userEmail: this.config.userEmail + }); + this.sessionId = session.sessionId; + this.setStatus("ready"); + } catch (err) { + this.setStatus("error", err instanceof Error ? err.message : String(err)); + throw err; + } + } + /** + * Submit a user message. Appends the user bubble immediately, then streams the + * assistant reply token-by-token, finalizing on `eventual_response`. + */ + async send(text) { + const trimmed = text.trim(); + if (!trimmed) return; + if (!this.client || !this.sessionId || this.status !== "ready") await this.connect(); + if (!this.client || !this.sessionId) throw new Error("Conversation is not connected"); + this.messages.push({ + id: this.nextId("u"), + role: "user", + text: trimmed, + streaming: false + }); + const assistant = { + id: this.nextId("a"), + role: "assistant", + text: "", + streaming: true + }; + this.messages.push(assistant); + this.emitMessages(); + try { + const turn = this.client.sendMessage({ + sessionId: this.sessionId, + message: trimmed, + stream: true + }); + for await (const event of turn) if (event.type === "stream_token") { + const token = event.token ?? event.data?.token ?? ""; + if (token) { + assistant.text += token; + this.emitMessages(); + } + } + const inner = (await turn).data?.data; + const finalText = extractFinalText(inner?.response); + if (finalText && finalText.length > assistant.text.length) assistant.text = finalText; + if (!assistant.text) assistant.text = "(no response)"; + const citations = extractCitations(inner); + if (citations.length > 0) assistant.citations = citations; + assistant.streaming = false; + this.emitMessages(); + } catch (err) { + assistant.streaming = false; + const message = err instanceof ProtocolError ? `Error: ${err.message}` : this.config.connectionErrorMessage ?? "We couldn't reach the chat."; + assistant.text = assistant.text ? `${assistant.text}\n\n${message}` : message; + this.emitMessages(); + this.setStatus("error", err instanceof Error ? err.message : String(err)); + } + } + /** Tear down the underlying client. */ + disconnect() { + this.client?.disconnect("widget closed"); + this.client = null; + this.sessionId = null; + this.setStatus("closed"); + } + }; + //#endregion + //#region src/widget/logo.ts + /** + * The Smooth logo, inlined as an SVG string so the full-page header can render + * it without a separate network fetch (the IIFE bundle is self-contained). + * + * GENERATED from `assets/smooth-logo.svg` — do not edit by hand. Regenerate with: + * node -e ... (see the commit that added this file) + */ + const SMOOTH_LOGO_SVG = "\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n"; + //#endregion + //#region src/widget/styles.ts + /** + * Render the widget's scoped stylesheet. All theme values are injected as CSS + * custom properties on `:host` so they can be overridden per-instance and so the + * styles below stay static. Kept deliberately framework-light — no Tailwind, no + * runtime CSS-in-JS; just a string the web component drops into its shadow root. + * + * `mode` switches the host positioning + panel sizing between the floating + * popover (default) and the full-page layout (fills its container/viewport). + */ + function buildStyles(theme, mode = "popover") { + return ` +:host { + --sac-text: ${theme.text}; + --sac-bg: ${theme.background}; + --sac-primary: ${theme.primary}; + --sac-primary-text: ${theme.primaryText}; + --sac-assistant-bubble: ${theme.assistantBubble}; + --sac-assistant-bubble-text: ${theme.assistantBubbleText}; + --sac-user-bubble: ${theme.userBubble}; + --sac-user-bubble-text: ${theme.userBubbleText}; + --sac-border: ${theme.border}; + + ${mode === "fullpage" ? `/* Full-page: fill the host's box (the element should be sized by its + container, or it falls back to filling the viewport). */ + display: block; + position: relative; + width: 100%; + height: 100%; + min-height: 100vh;` : `/* Popover: float in the bottom-right corner. */ + position: fixed; + bottom: 20px; + right: 20px; + z-index: 2147483000;`} + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; +} + +* { box-sizing: border-box; } + +.launcher { + width: 56px; + height: 56px; + border-radius: 50%; + border: none; + cursor: pointer; + background: var(--sac-primary); + color: var(--sac-primary-text); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + transition: transform 0.15s ease; +} +.launcher:hover { transform: scale(1.05); } + +.panel { + width: 360px; + max-width: calc(100vw - 40px); + height: 520px; + max-height: calc(100vh - 40px); + display: flex; + flex-direction: column; + background: var(--sac-bg); + color: var(--sac-text); + border: 1px solid var(--sac-border); + border-radius: 14px; + overflow: hidden; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); +} + +/* Full-page: the panel becomes the whole surface — no floating box, no shadow, + no rounded corners; it fills the host. */ +.panel.fullpage { + width: 100%; + height: 100%; + min-height: 100vh; + max-width: none; + max-height: none; + border: none; + border-radius: 0; + box-shadow: none; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + background: var(--sac-primary); + color: var(--sac-primary-text); +} +.header .brand { display: flex; align-items: center; gap: 10px; min-width: 0; } +.header .logo { height: 24px; width: auto; display: block; } +.header .title { font-weight: 600; font-size: 15px; } +.header .status { font-size: 11px; opacity: 0.85; } +.header .powered { + font-size: 10px; + opacity: 0.7; + letter-spacing: 0.02em; +} +.header .close { + background: transparent; + border: none; + color: inherit; + cursor: pointer; + font-size: 18px; + line-height: 1; + padding: 4px; +} + +/* Full-page header: taller, logo-led, centered max-width content row. */ +.panel.fullpage .header { padding: 14px 20px; } +.panel.fullpage .logo { height: 30px; } + +.messages { + flex: 1; + overflow-y: auto; + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.bubble { + max-width: 80%; + padding: 9px 12px; + border-radius: 12px; + font-size: 14px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} +.bubble.assistant { + align-self: flex-start; + background: var(--sac-assistant-bubble); + color: var(--sac-assistant-bubble-text); + border-bottom-left-radius: 4px; +} +.bubble.user { + align-self: flex-end; + background: var(--sac-user-bubble); + color: var(--sac-user-bubble-text); + border-bottom-right-radius: 4px; +} +.bubble.greeting { opacity: 0.85; font-style: italic; } + +/* Full-page: center the conversation in a readable column and let bubbles + breathe a little wider. */ +.panel.fullpage .messages { + padding: 24px 20px; + align-items: stretch; +} +.panel.fullpage .messages > * { + width: 100%; + max-width: 760px; + margin-left: auto; + margin-right: auto; +} +.panel.fullpage .bubble { max-width: 100%; } +.panel.fullpage .bubble.user { align-self: flex-end; max-width: 80%; margin-right: auto; } +.panel.fullpage .bubble.assistant { align-self: flex-start; max-width: 100%; } + +/* Sources panel — rendered under an assistant bubble whose terminal + eventual_response carried citations. */ +.sources { + align-self: flex-start; + max-width: 80%; + margin-top: -4px; + font-size: 12.5px; + color: var(--sac-text); +} +.panel.fullpage .sources { max-width: 100%; } +.sources details { background: transparent; } +.sources summary { + cursor: pointer; + font-weight: 600; + opacity: 0.85; + list-style: none; + user-select: none; + padding: 2px 0; +} +.sources summary::-webkit-details-marker { display: none; } +.sources summary::before { + content: '▸'; + display: inline-block; + margin-right: 6px; + transition: transform 0.15s ease; +} +.sources details[open] summary::before { transform: rotate(90deg); } +.sources ol { + margin: 6px 0 0; + padding-left: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; +} +.sources li { + border-left: 2px solid var(--sac-primary); + padding-left: 10px; +} +.sources .src-title { + color: var(--sac-primary); + text-decoration: none; + font-weight: 600; + word-break: break-word; +} +.sources a.src-title:hover { text-decoration: underline; } +.sources span.src-title { color: var(--sac-text); opacity: 0.95; } +.sources .src-snippet { + display: block; + margin-top: 2px; + opacity: 0.7; + line-height: 1.4; + white-space: normal; +} + +.cursor::after { + content: '▋'; + margin-left: 1px; + animation: sac-blink 1s steps(2, start) infinite; +} +@keyframes sac-blink { to { visibility: hidden; } } + +.composer { + display: flex; + gap: 8px; + padding: 10px; + border-top: 1px solid var(--sac-border); +} +.composer textarea { + flex: 1; + resize: none; + border: 1px solid var(--sac-border); + border-radius: 8px; + padding: 8px 10px; + font-family: inherit; + font-size: 14px; + background: transparent; + color: var(--sac-text); + max-height: 96px; + line-height: 1.4; +} +.composer textarea:focus { outline: 1px solid var(--sac-primary); } +.composer button { + border: none; + border-radius: 8px; + padding: 0 14px; + cursor: pointer; + background: var(--sac-primary); + color: var(--sac-primary-text); + font-weight: 600; + font-size: 14px; +} +.composer button:disabled { opacity: 0.5; cursor: default; } + +.hidden { display: none !important; } +`; + } + //#endregion + //#region src/widget/element.ts + const ELEMENT_TAG = "smooth-agent-chat"; + const OBSERVED = [ + "endpoint", + "agent-id", + "agent-name", + "placeholder", + "greeting", + "start-open", + "mode" + ]; + /** + * Return `url` only if it is a valid absolute `http(s)` URL, else `null`. + * + * SECURITY: citation URLs originate from indexed content (web / GitHub + * connectors), which can be attacker-influenceable. Assigning an arbitrary + * string to `.href` allows `javascript:`/`data:`/`vbscript:` URLs that + * execute on click — a stored-XSS vector. Only http(s) links are rendered as + * anchors; anything else falls back to plain text. + */ + function safeHttpUrl(url) { + if (!url) return null; + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null; + } catch { + return null; + } + } + var SmoothAgentChatElement = class extends HTMLElement { + static get observedAttributes() { + return OBSERVED; + } + root; + controller = null; + overrides = {}; + open = false; + messages = []; + status = "idle"; + mounted = false; + panelEl = null; + launcherEl = null; + messagesEl = null; + statusEl = null; + inputEl = null; + sendBtn = null; + constructor() { + super(); + this.root = this.attachShadow({ mode: "open" }); + } + connectedCallback() { + this.mounted = true; + this.render(); + } + disconnectedCallback() { + this.mounted = false; + this.controller?.disconnect(); + this.controller = null; + } + attributeChangedCallback() { + if (this.mounted) this.render(); + } + /** + * Programmatically merge config overrides (endpoint, agentId, theme, …). Values + * set here take precedence over HTML attributes. Re-renders the widget. + */ + configure(config) { + this.overrides = { + ...this.overrides, + ...config + }; + if (config.theme) this.overrides.theme = { + ...this.overrides.theme ?? {}, + ...config.theme + }; + if (this.mounted) this.render(); + } + /** Open the chat panel. */ + openChat() { + this.open = true; + this.syncOpenState(); + this.controller?.connect().catch(() => {}); + } + /** Collapse the chat panel back to the launcher. */ + closeChat() { + this.open = false; + this.syncOpenState(); + } + readConfig() { + const endpoint = this.overrides.endpoint ?? this.getAttribute("endpoint") ?? ""; + const agentId = this.overrides.agentId ?? this.getAttribute("agent-id") ?? ""; + if (!endpoint || !agentId) return null; + const theme = this.overrides.theme; + const modeAttr = this.getAttribute("mode"); + return { + endpoint, + mode: this.overrides.mode ?? (modeAttr === "fullpage" ? "fullpage" : modeAttr === "popover" ? "popover" : void 0) ?? "popover", + agentId, + agentName: this.overrides.agentName ?? this.getAttribute("agent-name") ?? void 0, + userName: this.overrides.userName, + userEmail: this.overrides.userEmail, + placeholder: this.overrides.placeholder ?? this.getAttribute("placeholder") ?? void 0, + greeting: this.overrides.greeting ?? this.getAttribute("greeting") ?? void 0, + connectionErrorMessage: this.overrides.connectionErrorMessage, + startOpen: this.overrides.startOpen ?? this.hasAttribute("start-open"), + theme + }; + } + render() { + const config = this.readConfig(); + if (!config) { + this.root.innerHTML = ""; + return; + } + const resolved = resolveConfig(config); + if (!this.controller) { + this.controller = new ConversationController(config, { + onMessages: (messages) => { + this.messages = messages; + this.renderMessages(resolved.greeting); + }, + onStatus: (status) => { + this.status = status; + this.renderStatus(); + this.renderComposerState(); + } + }); + if (resolved.startOpen) this.open = true; + } + const fullpage = resolved.mode === "fullpage"; + if (fullpage) this.open = true; + const style = document.createElement("style"); + style.textContent = buildStyles(resolved.theme, resolved.mode); + const headerBrand = fullpage ? `
+ ${SMOOTH_LOGO_SVG} +
+
${escapeHtml(resolved.agentName)}
+
+
+
+
powered by smooth-operator
` : `
+
+
${escapeHtml(resolved.agentName)}
+
+
+
+ `; + const container = document.createElement("div"); + container.innerHTML = ` + ${fullpage ? "" : ""} +
+
+ ${headerBrand} +
+
+
+ + +
+
+ `; + const logoSvg = container.querySelector(".logo-wrap svg"); + if (logoSvg) logoSvg.setAttribute("class", "logo"); + this.root.replaceChildren(style, container); + this.launcherEl = container.querySelector(".launcher"); + this.panelEl = container.querySelector(".panel"); + this.messagesEl = container.querySelector(".messages"); + this.statusEl = container.querySelector(".status"); + this.inputEl = container.querySelector("textarea"); + this.sendBtn = container.querySelector(".send"); + this.launcherEl?.addEventListener("click", () => this.openChat()); + container.querySelector(".close")?.addEventListener("click", () => this.closeChat()); + this.sendBtn?.addEventListener("click", () => this.submit()); + this.inputEl?.addEventListener("keydown", (ev) => { + if (ev.key === "Enter" && !ev.shiftKey) { + ev.preventDefault(); + this.submit(); + } + }); + if (fullpage) this.controller?.connect().catch(() => {}); + this.syncOpenState(); + this.renderMessages(resolved.greeting); + this.renderStatus(); + this.renderComposerState(); + } + syncOpenState() { + if (this.panelEl?.classList.contains("fullpage")) { + this.inputEl?.focus(); + return; + } + this.panelEl?.classList.toggle("hidden", !this.open); + this.launcherEl?.classList.toggle("hidden", this.open); + if (this.open) this.inputEl?.focus(); + } + renderMessages(greeting) { + if (!this.messagesEl) return; + this.messagesEl.replaceChildren(); + if (this.messages.length === 0 && greeting) { + const g = document.createElement("div"); + g.className = "bubble assistant greeting"; + g.textContent = greeting; + this.messagesEl.appendChild(g); + } + for (const msg of this.messages) { + const el = document.createElement("div"); + el.className = `bubble ${msg.role}`; + if (msg.streaming && !msg.text) el.classList.add("cursor"); + else if (msg.streaming) { + el.classList.add("cursor"); + el.textContent = msg.text; + } else el.textContent = msg.text; + this.messagesEl.appendChild(el); + if (msg.role === "assistant" && !msg.streaming && msg.citations && msg.citations.length > 0) this.messagesEl.appendChild(this.renderSources(msg.citations)); + } + this.messagesEl.scrollTop = this.messagesEl.scrollHeight; + } + /** + * Build the collapsible "Sources (N)" block for an assistant message's + * citations. Each source renders its `title` (linked to `citation.url` when + * present — `target=_blank rel=noopener` — plain text otherwise) plus the + * grounding `snippet`. Built with DOM APIs (not innerHTML) so citation text + * can't inject markup. + */ + renderSources(citations) { + const wrap = document.createElement("div"); + wrap.className = "sources"; + wrap.setAttribute("part", "sources"); + const details = document.createElement("details"); + details.open = true; + const summary = document.createElement("summary"); + summary.textContent = `Sources (${citations.length})`; + details.appendChild(summary); + const list = document.createElement("ol"); + for (const c of citations) { + const li = document.createElement("li"); + let titleEl; + const safeUrl = safeHttpUrl(c.url); + if (safeUrl) { + const a = document.createElement("a"); + a.className = "src-title"; + a.href = safeUrl; + a.target = "_blank"; + a.rel = "noopener noreferrer"; + titleEl = a; + } else { + titleEl = document.createElement("span"); + titleEl.className = "src-title"; + } + titleEl.textContent = c.title || c.id || "Source"; + li.appendChild(titleEl); + if (c.snippet) { + const snip = document.createElement("span"); + snip.className = "src-snippet"; + snip.textContent = c.snippet; + li.appendChild(snip); + } + list.appendChild(li); + } + details.appendChild(list); + wrap.appendChild(details); + return wrap; + } + renderStatus() { + if (!this.statusEl) return; + const label = { + idle: "", + connecting: "Connecting…", + ready: "Online", + error: "Connection issue", + closed: "Disconnected" + }; + this.statusEl.textContent = label[this.status]; + } + renderComposerState() { + const busy = this.status === "connecting"; + if (this.sendBtn) this.sendBtn.disabled = busy; + if (this.inputEl) this.inputEl.disabled = busy; + } + submit() { + if (!this.inputEl || !this.controller) return; + const text = this.inputEl.value; + if (!text.trim()) return; + this.inputEl.value = ""; + this.controller.send(text); + } + }; + function escapeHtml(value) { + return value.replace(/[&<>"']/g, (c) => { + switch (c) { + case "&": return "&"; + case "<": return "<"; + case ">": return ">"; + case "\"": return """; + default: return "'"; + } + }); + } + /** Register the custom element once. Safe to call multiple times. */ + function defineChatWidget() { + if (typeof customElements !== "undefined" && !customElements.get("smooth-agent-chat")) customElements.define(ELEMENT_TAG, SmoothAgentChatElement); + } + /** + * Programmatically create, configure, and append a widget to the page. + * Returns the element so the host can drive `openChat()` / `closeChat()`. + */ + function mountChatWidget(config, target = document.body) { + defineChatWidget(); + const el = document.createElement(ELEMENT_TAG); + el.configure(config); + target.appendChild(el); + return el; + } + /** + * Ergonomic helper for the full-page layout: mounts a `` in + * `mode: "fullpage"` (no launcher — the chat fills its container/viewport with a + * Smooth-branded header, a scrollable message list, and an input bar) and + * returns the element. + * + * `target` defaults to `document.body`; pass a sized container to embed the + * full-page chat inside a layout region (e.g. a `/chat` route shell or an + * iframe). The `mode` is forced to `"fullpage"` regardless of the passed config. + * + * ```ts + * mountFullPageChat({ endpoint: 'wss://…/ws', agentId: '…', agentName: 'Support' }); + * ``` + */ + function mountFullPageChat(config, target = document.body) { + return mountChatWidget({ + ...config, + mode: "fullpage" + }, target); + } + //#endregion + //#region src/widget/standalone.ts + defineChatWidget(); + /** Convenience alias matching the global API surface (`SmoothAgentChat.mount`). */ + function mount(config, target) { + return mountChatWidget(config, target); + } + /** + * Full-page convenience alias (`SmoothAgentChat.mountFullPage`): mounts the chat + * in `mode: "fullpage"` so it fills its container/viewport with no launcher. + */ + function mountFullPage(config, target) { + return mountFullPageChat(config, target); + } + //#endregion + exports.SmoothAgentChatElement = SmoothAgentChatElement; + exports.defineChatWidget = defineChatWidget; + exports.mount = mount; + exports.mountChatWidget = mountChatWidget; + exports.mountFullPage = mountFullPage; + exports.mountFullPageChat = mountFullPageChat; + return exports; +})({}); + +//# sourceMappingURL=chat-widget.iife.js.map \ No newline at end of file diff --git a/rust/smooth-operator-server/assets/widget-index.html b/rust/smooth-operator-server/assets/widget-index.html new file mode 100644 index 0000000..3b31343 --- /dev/null +++ b/rust/smooth-operator-server/assets/widget-index.html @@ -0,0 +1,42 @@ + + + + + + Smooth + + + + + + + + + diff --git a/rust/smooth-operator-server/src/local.rs b/rust/smooth-operator-server/src/local.rs index 95f2769..b6de70b 100644 --- a/rust/smooth-operator-server/src/local.rs +++ b/rust/smooth-operator-server/src/local.rs @@ -63,7 +63,8 @@ use anyhow::{Context, Result}; use tokio::net::TcpListener; use tokio::task::JoinHandle; -use smooth_operator::auth::NoAuthVerifier; +use smooth_operator::auth::{AuthVerifier, NoAuthVerifier}; +use smooth_operator::tool_provider::ToolProvider; use crate::config::{ServerConfig, StorageBackend}; use crate::server::{build_state, router}; @@ -81,11 +82,29 @@ pub const DEFAULT_LOCAL_ADDR: &str = "127.0.0.1:8787"; /// All knobs are optional — `LocalServer::builder().spawn().await` boots the /// default flavor (in-memory everything, loopback `:8787`, no auth, no seed). /// Construct with [`LocalServer::builder`]. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct LocalServerBuilder { addr: SocketAddr, seed_kb: bool, config: Option, + auth: Option>, + tool_provider: Option>, + serve_widget: bool, + widget_token: Option, +} + +impl std::fmt::Debug for LocalServerBuilder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LocalServerBuilder") + .field("addr", &self.addr) + .field("seed_kb", &self.seed_kb) + .field("config", &self.config) + // Never print the verifier's secrets — just its mode label. + .field("auth", &self.auth.as_ref().map(|a| a.mode())) + .field("tool_provider", &self.tool_provider.is_some()) + .field("serve_widget", &self.serve_widget) + .finish() + } } impl Default for LocalServerBuilder { @@ -96,6 +115,10 @@ impl Default for LocalServerBuilder { .expect("DEFAULT_LOCAL_ADDR is a valid SocketAddr"), seed_kb: false, config: None, + auth: None, + tool_provider: None, + serve_widget: false, + widget_token: None, } } } @@ -119,6 +142,42 @@ impl LocalServerBuilder { self } + /// Install a custom [`AuthVerifier`] for the local flavor. + /// + /// Without this, the local flavor runs auth-off ([`NoAuthVerifier`]) — fine + /// for pure loopback. Pass a + /// [`LocalTokenVerifier`](smooth_operator::auth::LocalTokenVerifier) to gate + /// stray local processes with a shared secret (recommended when binding + /// beyond loopback, e.g. over a tailnet). + #[must_use] + pub fn auth(mut self, auth: Arc) -> Self { + self.auth = Some(auth); + self + } + + /// Install a host [`ToolProvider`] so the runner merges its per-turn tools + /// into every turn alongside the built-ins (the `#68` injection seam). The + /// local flavor uses this to add an OS-sandboxed shell + egress-routed tools + /// — the isolation the cloud flavor gets from its container/network sandbox + /// instead. + #[must_use] + pub fn tools(mut self, provider: Arc) -> Self { + self.tool_provider = Some(provider); + self + } + + /// Serve the official `@smooai/smooth-operator` widget from this server: a + /// host page at `/` and the bundle at `/chat-widget.iife.js`. `token` is + /// injected into the page so the widget connects to this server's + /// `/ws?token=…` (pair it with a matching [`auth`](Self::auth) verifier); + /// pass `None` for a no-auth local server. + #[must_use] + pub fn serve_widget(mut self, token: Option) -> Self { + self.serve_widget = true; + self.widget_token = token; + self + } + /// Override the full [`ServerConfig`] (e.g. to point at a gateway / model). /// /// The local flavor still **forces** in-memory storage and the caller's bind @@ -146,9 +205,21 @@ impl LocalServerBuilder { config.seed_kb = self.seed_kb; // `build_state` gives in-memory storage + in-memory backplane + permissive - // widget auth. Install the no-op verifier explicitly so the admin API is - // reachable in-process without an `AUTH_MODE=none` env handshake. - build_state(config).with_auth(Arc::new(NoAuthVerifier::default())) + // widget auth. Install the caller's verifier, or default to the no-op one + // so the admin API is reachable in-process without an `AUTH_MODE=none` + // env handshake. + let auth = self + .auth + .clone() + .unwrap_or_else(|| Arc::new(NoAuthVerifier::default()) as Arc); + let mut state = build_state(config).with_auth(auth); + if let Some(provider) = &self.tool_provider { + state = state.with_tools(Arc::clone(provider)); + } + if self.serve_widget { + state = state.with_widget(self.widget_token.clone()); + } + state } /// Bind and spawn the server in a background task, returning a [`LocalServer`] @@ -328,4 +399,57 @@ mod tests { // The no-op verifier is installed (admin reachable in-process). assert_eq!(state.auth.mode(), "none"); } + + #[test] + fn auth_seam_installs_a_custom_verifier() { + use smooth_operator::auth::LocalTokenVerifier; + let state = LocalServerBuilder::default() + .auth(Arc::new(LocalTokenVerifier::new("s3cret"))) + .build(); + assert_eq!( + state.auth.mode(), + "local-token", + "custom verifier overrides the default" + ); + } + + #[test] + fn tools_seam_installs_a_provider() { + use async_trait::async_trait; + use smooth_operator::tool_provider::{ToolProvider, ToolProviderContext}; + use smooth_operator_core::Tool; + + struct EmptyProvider; + #[async_trait] + impl ToolProvider for EmptyProvider { + async fn tools_for(&self, _ctx: &ToolProviderContext) -> Vec> { + Vec::new() + } + } + let state = LocalServerBuilder::default() + .tools(Arc::new(EmptyProvider)) + .build(); + assert!(state.tool_provider.is_some(), "host ToolProvider installed"); + } + + #[test] + fn serve_widget_opts_into_the_widget_routes_with_token() { + let state = LocalServerBuilder::default() + .serve_widget(Some("tok-123".into())) + .build(); + assert!(state.serve_widget, "widget routes opted in"); + assert_eq!(state.widget_token.as_deref(), Some("tok-123")); + // Building the router with serve_widget set mounts `/` + the bundle route. + let _ = crate::server::router(state); + } + + #[test] + fn no_widget_by_default() { + let state = LocalServerBuilder::default().build(); + assert!( + !state.serve_widget, + "widget off by default (K8s/Lambda never serve it)" + ); + assert_eq!(state.widget_token, None); + } } diff --git a/rust/smooth-operator-server/src/server.rs b/rust/smooth-operator-server/src/server.rs index 72b2518..f74a697 100644 --- a/rust/smooth-operator-server/src/server.rs +++ b/rust/smooth-operator-server/src/server.rs @@ -19,7 +19,7 @@ use std::sync::Arc; use anyhow::{Context, Result}; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::{Query, State}; -use axum::response::Response; +use axum::response::{Html, IntoResponse, Response}; use axum::routing::get; use axum::Router; @@ -39,7 +39,7 @@ use crate::state::AppState; /// can boot the server in-process. Serves the WebSocket `/ws` endpoint plus the /// auth-gated admin HTTP API under `/admin` (see [`crate::admin`]). pub fn router(state: AppState) -> Router { - Router::new() + let mut router = Router::new() .route("/ws", get(ws_upgrade)) // Unauthenticated liveness/readiness probe. A WebSocket `/ws` upgrade is // not a plain GET, so HTTP load balancers (AWS ALB / nginx ingress) need a @@ -47,8 +47,38 @@ pub fn router(state: AppState) -> Router { // and dependency-free — it does not touch storage/LLM, so it stays Ready // even when an optional backend (gateway key, DB) is degraded. .route("/health", get(health)) - .merge(crate::admin::router()) - .with_state(state) + .merge(crate::admin::router()); + // The local deployment flavor opts into serving the official widget; other + // flavors never mount these routes. + if state.serve_widget { + router = router + .route("/", get(widget_index)) + .route("/chat-widget.iife.js", get(widget_bundle)); + } + router.with_state(state) +} + +/// Serve the local-flavor widget host page at `/`, injecting the auth token +/// (same-origin, loopback) so the embedded `` connects to +/// this server's own `/ws?token=…`. The token is JSON-encoded into the page so +/// quoting is always safe. +async fn widget_index(State(state): State) -> Html { + let token_json = serde_json::to_string(state.widget_token.as_deref().unwrap_or("")) + .unwrap_or_else(|_| "\"\"".to_string()); + let html = + include_str!("../assets/widget-index.html").replace("__SMOOTH_LOCAL_TOKEN__", &token_json); + Html(html) +} + +/// Serve the vendored official widget IIFE bundle. +async fn widget_bundle() -> impl IntoResponse { + ( + [( + axum::http::header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + )], + include_str!("../assets/chat-widget.iife.js"), + ) } /// `GET /health` → `200 OK`. The minimal HTTP health endpoint for container diff --git a/rust/smooth-operator-server/src/state.rs b/rust/smooth-operator-server/src/state.rs index 2e17a5e..bba74ba 100644 --- a/rust/smooth-operator-server/src/state.rs +++ b/rust/smooth-operator-server/src/state.rs @@ -119,6 +119,15 @@ pub struct AppState { /// session has at most one outstanding confirmation; an empty map means no /// turn is parked (the default, byte-for-byte unchanged from before HITL). pending_confirmations: Arc>>>, + /// When `true`, the router mounts the embedded widget host page at `/` and + /// the widget bundle at `/chat-widget.iife.js`. Off by default (the + /// K8s/Lambda flavors never serve the widget); the local flavor opts in via + /// [`with_widget`](Self::with_widget). + pub serve_widget: bool, + /// The auth token injected into the served widget host page (same-origin), so + /// the embedded widget connects to this server's `/ws?token=…`. `None` ⇒ no + /// token injected (a no-auth local server). + pub widget_token: Option, } /// Namespace a connector name by org for the [`IndexingStore`] key, so two orgs @@ -165,6 +174,8 @@ impl AppState { doc_sets: Arc::new(RwLock::new(HashMap::new())), connectors: Arc::new(RwLock::new(HashMap::new())), pending_confirmations: Arc::new(RwLock::new(HashMap::new())), + serve_widget: false, + widget_token: None, } } @@ -206,6 +217,17 @@ impl AppState { self } + /// Serve the embedded official widget (host page at `/`, bundle at + /// `/chat-widget.iife.js`), injecting `token` into the page so the widget + /// connects to this server's `/ws?token=…` (builder). The local deployment + /// flavor opts in; other flavors never mount the widget routes. + #[must_use] + pub fn with_widget(mut self, token: Option) -> Self { + self.serve_widget = true; + self.widget_token = token; + self + } + /// Install the embeddable-widget auth provider (builder). A host backs this /// with its agent store so embed origins + public keys are enforced. #[must_use] diff --git a/rust/smooth-operator/src/auth.rs b/rust/smooth-operator/src/auth.rs index b763a34..7886e67 100644 --- a/rust/smooth-operator/src/auth.rs +++ b/rust/smooth-operator/src/auth.rs @@ -492,6 +492,69 @@ impl AuthVerifier for NoAuthVerifier { } } +/// **Local single-user** verifier — the auth for the *local deployment flavor*. +/// +/// Holds one shared secret (the local daemon auto-provisions it). The presented +/// token must equal the secret, compared in **constant time**; on match the +/// connection runs as a fixed local `Admin` principal, and on mismatch/empty it +/// **fails closed**. This gates stray local processes from connecting to the +/// loopback/tailnet server without dragging in the multi-tenant JWT/IdP +/// machinery — exactly the posture a single-user always-on daemon wants. +/// +/// The token rides in the **same slot** a JWT would: the `/ws` `?token=` query +/// param (reference server) or the `send_message` `token` field (Lambda), so all +/// existing transport plumbing is reused. +pub struct LocalTokenVerifier { + secret: String, + principal: Principal, +} + +impl LocalTokenVerifier { + /// A verifier over `secret`; matched connections run as a local `Admin`. + #[must_use] + pub fn new(secret: impl Into) -> Self { + Self { + secret: secret.into(), + principal: Principal::new( + "local", + "local", + Role::Admin, + Some("Local user".to_string()), + ), + } + } +} + +impl AuthVerifier for LocalTokenVerifier { + fn verify(&self, bearer_token: &str) -> Result { + if bearer_token.is_empty() { + return Err(AuthError::Unauthenticated); + } + if local_token_eq(bearer_token.as_bytes(), self.secret.as_bytes()) { + Ok(self.principal.clone()) + } else { + Err(AuthError::InvalidToken("local token mismatch".to_string())) + } + } + + fn mode(&self) -> &'static str { + "local-token" + } +} + +/// Length-aware constant-time byte comparison, so the local-token check leaks +/// neither length nor content through timing. +fn local_token_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b) { + diff |= x ^ y; + } + diff == 0 +} + /// **Tokenless trusted-upstream** verifier — `AUTH_MODE=trusted`. /// /// For the **proxied-integration** deployment shape: an existing application's @@ -1005,6 +1068,29 @@ mod tests { assert_eq!(verifier.mode(), "none"); } + // ---- LocalTokenVerifier ---------------------------------------------- + + #[test] + fn local_token_accepts_exact_secret_as_local_admin() { + let v = LocalTokenVerifier::new("s3cret-local"); + let p = v.verify("s3cret-local").expect("matching token"); + assert_eq!(p.role, Role::Admin); + assert_eq!(p.user_id, "local"); + assert_eq!(p.org_id, "local"); + assert_eq!(v.mode(), "local-token"); + } + + #[test] + fn local_token_fails_closed_on_wrong_or_empty() { + let v = LocalTokenVerifier::new("s3cret-local"); + assert!(matches!(v.verify(""), Err(AuthError::Unauthenticated))); + assert!(matches!(v.verify("nope"), Err(AuthError::InvalidToken(_)))); + assert!(matches!( + v.verify("s3cret"), + Err(AuthError::InvalidToken(_)) + )); + } + // ---- AuthConfig::from_env — secure by default ------------------------ // // These mutate process env, so they run serially under a shared lock to diff --git a/rust/smooth-operator/src/lib.rs b/rust/smooth-operator/src/lib.rs index cc3abc0..935466b 100644 --- a/rust/smooth-operator/src/lib.rs +++ b/rust/smooth-operator/src/lib.rs @@ -39,8 +39,8 @@ pub mod widget_auth; pub use access_control::{AccessContext, AclKnowledgeStore, DocAcl}; pub use adapter::{ConversationUpdate, MessagePage, MessageQuery, SessionUpdate, StorageAdapter}; pub use auth::{ - AuthConfig, AuthError, AuthVerifier, JwtVerifier, NoAuthVerifier, Principal, Role, - SmooIdentityVerifier, + AuthConfig, AuthError, AuthVerifier, JwtVerifier, LocalTokenVerifier, NoAuthVerifier, + Principal, Role, SmooIdentityVerifier, }; pub use connector_config::{ ConnectorConfig, ConnectorConfigStore, ConnectorKind, InMemoryConnectorConfigStore,