diff --git a/docs/frameworks/EMBEDDED-SERVICES.md b/docs/frameworks/EMBEDDED-SERVICES.md index 05332ab994..dc04a38677 100644 --- a/docs/frameworks/EMBEDDED-SERVICES.md +++ b/docs/frameworks/EMBEDDED-SERVICES.md @@ -1,13 +1,13 @@ --- title: "Embedded Services" -description: "Reference for 9Router, CLIProxyAPI, Mux, and Bifrost" +description: "Reference for 9Router, CLIProxyAPI, Mux, Bifrost, and open-wa" --- # Embedded Services > **Version:** v3.8.44 -> **Last updated:** 2026-07-03 -> **Audience:** Engineers adding, maintaining, or debugging embedded services (9Router, CLIProxyAPI, Mux, Bifrost). +> **Last updated:** 2026-09-09 +> **Audience:** Engineers adding, maintaining, or debugging embedded services (9Router, CLIProxyAPI, Mux, Bifrost, open-wa). Embedded services are locally-installed process sidecar tools that OmniRoute installs, supervises, and exposes as first-class routing targets. Unlike external providers (which are reached over the internet @@ -32,7 +32,7 @@ via API keys), embedded services run on the same machine as OmniRoute and commun ### Why embedded services? -Five services are embedded: +Six services are embedded: | Service | npm package | Default port | Purpose | | --------------- | ---------------------------------- | :----------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -41,8 +41,9 @@ Five services are embedded: | **Mux** | `mux` (headless `mux server`) | 8322 | Local agent-orchestration daemon (coder/mux). Lifecycle-managed only — not a routing target (no LLM proxying). | | **Bifrost** | `@maximhq/bifrost` | 8080 | Go AI-gateway relay backend. When running, auto-selected by the relay route (`/v1/relay/`) | | **Dario** | `@askalf/dario` | 3456 | Claude-subscription proxy — alternative/failover to CLIProxyAPI for Claude-Code-shaped traffic; the injected key becomes `DARIO_ADMIN_TOKEN` gating its `/admin/*` OAuth control plane | +| **open-wa** | `@open-wa/wa-automate` | 8323 | WhatsApp Web automation (headless Chromium via Puppeteer). Lifecycle-managed only — not a routing target. | -All five follow the same supervisory model: +All six follow the same supervisory model: - OmniRoute installs them under `DATA_DIR/services/{name}/` (isolated from OmniRoute's own `package.json`) - OmniRoute spawns and monitors them as child processes @@ -112,7 +113,7 @@ All five follow the same supervisory model: │ modelSync.ts Periodic GET /v1/models → service_models table │ │ ringBuffer.ts Circular log buffer (5 MB per service) │ │ healthCheck.ts Polling HTTP health probe │ -│ installers/ ninerouter.ts, cliproxy.ts, mux.ts │ +│ installers/ ninerouter.ts, cliproxy.ts, mux.ts, openwa.ts │ │ (installer adapters) │ └──────────────────────┬─────────────────────────────────────────────┘ │ OpenAI-compatible HTTP (loopback) @@ -151,6 +152,7 @@ All five follow the same supervisory model: | `src/lib/services/installers/ninerouter.ts` | npm install/update/uninstall for 9Router | | `src/lib/services/installers/cliproxy.ts` | npm install/update/uninstall for CLIProxyAPI | | `src/lib/services/installers/mux.ts` | npm install/update/uninstall for Mux | +| `src/lib/services/installers/openwa.ts` | npm install/update/uninstall for open-wa | | `src/app/api/services/9router/_lib.ts` | `getOrInitSupervisor()` helper | | `src/app/api/services/[name]/logs/route.ts` | Shared SSE logs endpoint | | `open-sse/executors/ninerouter.ts` | Provider executor (Layer 4) | @@ -503,7 +505,42 @@ Same lifecycle shape as the other services (`install`, `start`, `stop`, `restart control plane under `admin/`: `admin/accounts`, `admin/import-from-omniroute`, `admin/login-start`, `admin/login-complete` (all behind `DARIO_ADMIN_TOKEN`). -### 4.6 Reverse proxy (9Router dashboard embed) +### 4.6 open-wa endpoints (7 routes) + +open-wa (`@open-wa/wa-automate`) drives a headless Chromium instance (via +Puppeteer) to automate WhatsApp Web. It uses the same endpoint shape as Mux (no +`rotate-key` route yet). It is lifecycle-managed only — not a routing target, +no Layer 4 executor/provider entry. + +| Method | Path | Description | +| ------ | --------------------------------- | ----------------------------------------------------- | +| `POST` | `/api/services/openwa/install` | Install open-wa from npm (`@open-wa/wa-automate`) | +| `POST` | `/api/services/openwa/start` | Start open-wa on port 8323 (default) | +| `POST` | `/api/services/openwa/stop` | Stop open-wa | +| `POST` | `/api/services/openwa/restart` | Restart open-wa | +| `POST` | `/api/services/openwa/update` | Update to newer version | +| `GET` | `/api/services/openwa/status` | Live + DB status | +| `POST` | `/api/services/openwa/auto-start` | Toggle auto-start | +| `GET` | `/api/services/openwa/logs` | SSE log tail (via shared `[name]/logs` dynamic route) | + +**API key:** injected as `WA_KEY` — open-wa's generic `WA_*`-prefixed env +override maps it onto the `--key`/`-k` CLI option +(`dist/cli/setup.js::envArgs()`, verified against the installed 4.76.0 +package). Prefixed `ow_` when generated by `generateServiceApiKey()`. open-wa +reads the key back from a `key`/`api_key` HTTP header (not `Authorization: +Bearer`); `/api-docs*` is explicitly exempted from the check +(`setupAuthenticationLayer` in `dist/cli/server.js`), so the health probe +needs no auth header. + +**Pairing:** open-wa is unofficial and unaffiliated with WhatsApp — the +connected number carries a ban risk from WhatsApp's own automation detection. +On first start, the pairing QR code is printed to stdout and surfaced through +the existing Logs panel/SSE stream — there is no dedicated QR-image endpoint +in this integration yet. + +--- + +### 4.7 Reverse proxy (9Router dashboard embed) The dashboard embeds the 9Router web UI inside an iframe via an internal reverse proxy at: diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 95d9778f48..b4082fbaab 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -4812,6 +4812,203 @@ paths: "500": description: Update failed + /api/services/openwa/install: + post: + tags: [Embedded Services] + summary: Install open-wa from npm + description: >- + Installs the `@open-wa/wa-automate` npm package (WhatsApp Web + automation via headless Chromium/Puppeteer) under + DATA_DIR/services/openwa/. Bundles a Puppeteer Chromium download, so + this install is much slower than the other embedded services. + **LOCAL_ONLY** — loopback only. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + version: + type: string + default: latest + responses: + "200": + description: Install succeeded + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + installedVersion: + type: string + "400": + description: Invalid request body + "500": + description: npm install failed + + /api/services/openwa/start: + post: + tags: [Embedded Services] + summary: Start open-wa + description: >- + Spawns `wa-automate --port --host 127.0.0.1 + --session-data-path data`. The HTTP API does not start listening + until the WhatsApp client handshake resolves, which blocks on a + human scanning the pairing QR code (shown in the logs panel) on + first pairing — state stays `starting` well past the other + services' typical cold-start window. Idempotent if already running. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service started + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + "409": + description: open-wa is not installed + "503": + description: Start failed + + /api/services/openwa/stop: + post: + tags: [Embedded Services] + summary: Stop open-wa + description: >- + Gracefully stops open-wa. Idempotent. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service stopped + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/openwa/restart: + post: + tags: [Embedded Services] + summary: Restart open-wa + description: >- + stop() then start() under the operation lock. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service restarted + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/openwa/update: + post: + tags: [Embedded Services] + summary: Update open-wa to a newer npm version + description: >- + Stops, installs newer version, restarts. + **LOCAL_ONLY** — loopback only. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + version: + type: string + default: latest + responses: + "200": + description: Update succeeded + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + installedVersion: + type: string + "500": + description: Update failed + + /api/services/openwa/status: + get: + tags: [Embedded Services] + summary: Get open-wa status + description: >- + Returns live supervisor state and DB metadata. `health` only reflects + whether `/api-docs/` answered — it does not indicate whether a + WhatsApp session is paired. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Status response + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/openwa/auto-start: + post: + tags: [Embedded Services] + summary: Toggle open-wa auto-start + description: >- + When enabled, open-wa starts automatically on the next OmniRoute boot. + **LOCAL_ONLY** — loopback only. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [enabled] + properties: + enabled: + type: boolean + responses: + "200": + description: Auto-start flag updated + content: + application/json: + schema: + type: object + properties: + autoStart: + type: boolean + "400": + description: Invalid request body + + /api/services/openwa/auto-restart-adopted: + post: + tags: [Embedded Services] + summary: Toggle open-wa auto-restart-when-adopted + description: >- + When enabled, an externally-adopted (not OmniRoute-spawned) open-wa + process is restarted under OmniRoute's own supervisor on the next + health-check cycle instead of being left as adopted-only. + **LOCAL_ONLY** — loopback only. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [enabled] + properties: + enabled: + type: boolean + responses: + "204": + description: Flag updated + "400": + description: Invalid request body + "500": + description: Update failed + /api/services/bifrost/install: post: tags: [Embedded Services] diff --git a/src/app/(dashboard)/dashboard/providers/services/page.tsx b/src/app/(dashboard)/dashboard/providers/services/page.tsx index f2a97128b4..45f36362a4 100644 --- a/src/app/(dashboard)/dashboard/providers/services/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/services/page.tsx @@ -8,8 +8,9 @@ import { NinerouterServiceTab } from "./tabs/NinerouterServiceTab"; import { MuxServiceTab } from "./tabs/MuxServiceTab"; import { BifrostServiceTab } from "./tabs/BifrostServiceTab"; import { DarioServiceTab } from "./tabs/DarioServiceTab"; +import { OpenwaServiceTab } from "./tabs/OpenwaServiceTab"; -type Tab = "cliproxy" | "9router" | "mux" | "bifrost" | "dario"; +type Tab = "cliproxy" | "9router" | "mux" | "bifrost" | "dario" | "openwa"; const TABS: { id: Tab; label: string; icon: string }[] = [ { id: "cliproxy", label: "CLIProxyAPI", icon: "swap_horiz" }, @@ -17,6 +18,7 @@ const TABS: { id: Tab; label: string; icon: string }[] = [ { id: "mux", label: "Mux", icon: "hub" }, { id: "bifrost", label: "Bifrost", icon: "bolt" }, { id: "dario", label: "Dario", icon: "shield_person" }, + { id: "openwa", label: "open-wa", icon: "chat" }, ]; export default function ServicesPage() { @@ -64,6 +66,7 @@ export default function ServicesPage() { {active === "mux" && } {active === "bifrost" && } {active === "dario" && } + {active === "openwa" && } ); diff --git a/src/app/(dashboard)/dashboard/providers/services/tabs/OpenwaServiceTab.tsx b/src/app/(dashboard)/dashboard/providers/services/tabs/OpenwaServiceTab.tsx new file mode 100644 index 0000000000..e8d9e0442f --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/services/tabs/OpenwaServiceTab.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { ServiceStatusCard } from "../components/ServiceStatusCard"; +import { ServiceLifecycleButtons } from "../components/ServiceLifecycleButtons"; +import { ServiceLogsPanel } from "../components/ServiceLogsPanel"; +import { AutoStartToggle } from "../components/AutoStartToggle"; +import { AutoRestartAdoptedToggle } from "../components/AutoRestartAdoptedToggle"; + +const NAME = "openwa"; + +export function OpenwaServiceTab() { + return ( +
+ {/* English literal, not an i18n key — mirrors the "adopted process" note + in ServiceStatusCard.tsx. open-wa is unofficial and unaffiliated with + WhatsApp; automating a personal number carries a ban risk. */} +

+ warning + + open-wa (@open-wa/wa-automate) is an unofficial WhatsApp Web automation library, not + affiliated with or endorsed by WhatsApp. The connected number can be banned by WhatsApp + for automated use. On first start, scan the pairing QR code from the Logs panel below — + the status stays "starting" until the scan completes (up to a few minutes), not + an error. + +

+ + + + + +
+ ); +} diff --git a/src/app/api/services/openwa/_lib.ts b/src/app/api/services/openwa/_lib.ts new file mode 100644 index 0000000000..34560226d0 --- /dev/null +++ b/src/app/api/services/openwa/_lib.ts @@ -0,0 +1,40 @@ +/** + * Shared helpers for /api/services/openwa/* route handlers. + * Creates a supervisor on demand if bootstrap hasn't registered one yet. + */ + +import { getSupervisor, registerSupervisor } from "@/lib/services/registry"; +import { ServiceSupervisor } from "@/lib/services/ServiceSupervisor"; +import { resolveSpawnArgs, OPENWA_DEFAULT_PORT } from "@/lib/services/installers/openwa"; +import { getOrCreateApiKey } from "@/lib/services/apiKey"; + +const TOOL = "openwa"; +const PORT = parseInt(process.env.OPENWA_SERVICE_PORT ?? String(OPENWA_DEFAULT_PORT), 10); + +export async function getOrInitSupervisor(): Promise { + const existing = getSupervisor(TOOL); + if (existing) return existing; + + const apiKey = await getOrCreateApiKey(TOOL); + + const sup = new ServiceSupervisor({ + tool: TOOL, + port: PORT, + spawnArgs: () => resolveSpawnArgs(apiKey, PORT), + healthUrl: () => `http://127.0.0.1:${PORT}/api-docs/`, + // 60s, not the usual 5s — open-wa doesn't listen on its HTTP port until + // the full WhatsApp handshake resolves, which blocks on a human QR scan + // on first pairing. See bootstrap.ts's SERVICES[] "openwa" entry comment + // for the full verified explanation (must stay in sync with this value). + healthIntervalMs: 60_000, + stopTimeoutMs: 30_000, + logsBufferBytes: 5_242_880, + // #6205: mirrors bootstrap.ts's own supervisor construction — adopt a + // healthy prior instance instead of crashing on-demand creation (e.g. a + // direct API hit before bootstrap runs) into a raw EADDRINUSE. + probeBeforeSpawn: true, + }); + + registerSupervisor(sup); + return sup; +} diff --git a/src/app/api/services/openwa/auto-restart-adopted/route.ts b/src/app/api/services/openwa/auto-restart-adopted/route.ts new file mode 100644 index 0000000000..cb605a9ebc --- /dev/null +++ b/src/app/api/services/openwa/auto-restart-adopted/route.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { updateServiceField } from "@/lib/db/versionManager"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const BodySchema = z.object({ enabled: z.boolean() }); + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return createErrorResponse({ status: 400, message: "Invalid JSON body" }); + } + + const parsed = BodySchema.safeParse(body); + if (!parsed.success) { + return createErrorResponse({ status: 400, message: parsed.error.message }); + } + + try { + await updateServiceField("openwa", "autoRestartAdopted", parsed.data.enabled); + return new Response(null, { status: 204 }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/app/api/services/openwa/auto-start/route.ts b/src/app/api/services/openwa/auto-start/route.ts new file mode 100644 index 0000000000..1c7a51dee5 --- /dev/null +++ b/src/app/api/services/openwa/auto-start/route.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { updateServiceField } from "@/lib/db/versionManager"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const BodySchema = z.object({ enabled: z.boolean() }); + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return createErrorResponse({ status: 400, message: "Invalid JSON body" }); + } + + const parsed = BodySchema.safeParse(body); + if (!parsed.success) { + return createErrorResponse({ status: 400, message: parsed.error.message }); + } + + try { + await updateServiceField("openwa", "autoStart", parsed.data.enabled); + return new Response(null, { status: 204 }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/app/api/services/openwa/install/route.ts b/src/app/api/services/openwa/install/route.ts new file mode 100644 index 0000000000..ff94790e99 --- /dev/null +++ b/src/app/api/services/openwa/install/route.ts @@ -0,0 +1,6 @@ +import { install } from "@/lib/services/installers/openwa"; +import { handleServiceInstall } from "@/app/api/services/_shared/installRoute"; + +export async function POST(request: Request): Promise { + return handleServiceInstall(request, install); +} diff --git a/src/app/api/services/openwa/restart/route.ts b/src/app/api/services/openwa/restart/route.ts new file mode 100644 index 0000000000..29e513b8dc --- /dev/null +++ b/src/app/api/services/openwa/restart/route.ts @@ -0,0 +1,22 @@ +import { getServiceRow } from "@/lib/db/versionManager"; +import { getOrInitSupervisor } from "../_lib"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const TOOL = "openwa"; + +export async function POST(): Promise { + try { + const row = await getServiceRow(TOOL); + if (!row || row.status === "not_installed") { + return createErrorResponse({ status: 409, message: "open-wa não está instalado." }); + } + + const sup = await getOrInitSupervisor(); + const status = await sup.restart(); + return Response.json(status); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 503, message: msg }); + } +} diff --git a/src/app/api/services/openwa/start/route.ts b/src/app/api/services/openwa/start/route.ts new file mode 100644 index 0000000000..d7cfe2d889 --- /dev/null +++ b/src/app/api/services/openwa/start/route.ts @@ -0,0 +1,22 @@ +import { getServiceRow } from "@/lib/db/versionManager"; +import { getOrInitSupervisor } from "../_lib"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const TOOL = "openwa"; + +export async function POST(): Promise { + try { + const row = await getServiceRow(TOOL); + if (!row || row.status === "not_installed") { + return createErrorResponse({ status: 409, message: "open-wa não está instalado." }); + } + + const sup = await getOrInitSupervisor(); + const status = await sup.start(); + return Response.json(status); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 503, message: msg }); + } +} diff --git a/src/app/api/services/openwa/status/route.ts b/src/app/api/services/openwa/status/route.ts new file mode 100644 index 0000000000..3ef584c897 --- /dev/null +++ b/src/app/api/services/openwa/status/route.ts @@ -0,0 +1,41 @@ +import { getSupervisor } from "@/lib/services/registry"; +import { getServiceRow } from "@/lib/db/versionManager"; +import { + getInstalledVersion, + getLatestVersion, + OPENWA_DEFAULT_PORT, +} from "@/lib/services/installers/openwa"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const TOOL = "openwa"; + +export async function GET(): Promise { + try { + const sup = getSupervisor(TOOL); + const row = await getServiceRow(TOOL); + + const liveStatus = sup?.getStatus() ?? null; + const installedVersion = await getInstalledVersion(); + const latestVersion = await getLatestVersion(); + + return Response.json({ + tool: TOOL, + state: liveStatus?.state ?? row?.status ?? "unknown", + pid: liveStatus?.pid ?? null, + port: liveStatus?.port ?? row?.port ?? OPENWA_DEFAULT_PORT, + health: liveStatus?.health ?? "unknown", + startedAt: liveStatus?.startedAt ?? null, + lastError: liveStatus?.lastError ?? row?.errorMessage ?? null, + installedVersion: installedVersion ?? row?.installedVersion ?? null, + latestVersion, + updateAvailable: !!installedVersion && !!latestVersion && installedVersion !== latestVersion, + autoStart: row?.autoStart ?? false, + adopted: liveStatus?.adopted ?? false, + autoRestartAdopted: row?.autoRestartAdopted ?? false, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/app/api/services/openwa/stop/route.ts b/src/app/api/services/openwa/stop/route.ts new file mode 100644 index 0000000000..1b9474ece3 --- /dev/null +++ b/src/app/api/services/openwa/stop/route.ts @@ -0,0 +1,19 @@ +import { getSupervisor } from "@/lib/services/registry"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const TOOL = "openwa"; + +export async function POST(): Promise { + try { + const sup = getSupervisor(TOOL); + if (!sup) { + return Response.json({ tool: TOOL, state: "stopped" }); + } + const status = await sup.stop(); + return Response.json(status); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/app/api/services/openwa/update/route.ts b/src/app/api/services/openwa/update/route.ts new file mode 100644 index 0000000000..e2be481543 --- /dev/null +++ b/src/app/api/services/openwa/update/route.ts @@ -0,0 +1,45 @@ +import { getSupervisor } from "@/lib/services/registry"; +import { getOrInitSupervisor } from "../_lib"; +import { + getInstalledVersion, + getLatestVersion, + update as downloadUpdate, +} from "@/lib/services/installers/openwa"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +export async function POST(): Promise { + try { + const [installed, latest] = await Promise.all([getInstalledVersion(), getLatestVersion()]); + + if (installed && latest && installed === latest) { + return Response.json({ updated: false, installedVersion: installed, latestVersion: latest }); + } + + const sup = getSupervisor("openwa"); + const wasRunning = sup?.getStatus().state === "running"; + + if (wasRunning && sup) { + await sup.stop(); + } + + const result = await downloadUpdate(); + + if (wasRunning) { + const freshSup = await getOrInitSupervisor(); + await freshSup.start().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[Services] Could not restart openwa after update:", msg); + }); + } + + return Response.json({ + updated: true, + oldVersion: installed ?? null, + newVersion: result.installedVersion, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/lib/db/migrations/180_openwa_service_seed.sql b/src/lib/db/migrations/180_openwa_service_seed.sql new file mode 100644 index 0000000000..aa625e9372 --- /dev/null +++ b/src/lib/db/migrations/180_openwa_service_seed.sql @@ -0,0 +1,14 @@ +-- Migration 185: Seed the open-wa (@open-wa/wa-automate) embedded service row. +-- +-- open-wa is a WhatsApp Web automation library (npm package +-- `@open-wa/wa-automate`, CLI server mode `wa-automate --port `) +-- managed via the ServiceSupervisor framework, same shape as Mux (114) and +-- Bifrost (115). It is lifecycle-managed only — not a routing target, so +-- provider_expose stays 0. Seeds a `not_installed` / `auto_start=0` +-- placeholder row so the dashboard tab and /api/services/openwa/status have +-- a row to read before install. + +INSERT OR IGNORE INTO version_manager + (tool, status, port, auto_start, auto_update, provider_expose) +VALUES + ('openwa', 'not_installed', 8323, 0, 0, 0); diff --git a/src/lib/services/apiKey.ts b/src/lib/services/apiKey.ts index 144cfd290e..9d2b6c5426 100644 --- a/src/lib/services/apiKey.ts +++ b/src/lib/services/apiKey.ts @@ -31,7 +31,15 @@ export async function getOrCreateApiKey(tool: string): Promise { // Dario reuses this mechanism to generate+persist its DARIO_ADMIN_TOKEN // (any long random string works — it gates the /admin/* control plane). const prefix = - tool === "9router" ? "nr" : tool === "mux" ? "mx" : tool === "dario" ? "da" : "cp"; + tool === "9router" + ? "nr" + : tool === "mux" + ? "mx" + : tool === "dario" + ? "da" + : tool === "openwa" + ? "ow" + : "cp"; const key = generateServiceApiKey(prefix); await updateServiceField(tool, "apiKey", encrypt(key) ?? key); return key; diff --git a/src/lib/services/bootstrap.ts b/src/lib/services/bootstrap.ts index df58e66d26..ddb41bad0d 100644 --- a/src/lib/services/bootstrap.ts +++ b/src/lib/services/bootstrap.ts @@ -12,6 +12,7 @@ import { import { resolveSpawnArgs as muxSpawnArgs, MUX_DEFAULT_PORT } from "./installers/mux"; import { resolveSpawnArgs as bifrostSpawnArgs, BIFROST_DEFAULT_PORT } from "./installers/bifrost"; import { resolveSpawnArgs as darioSpawnArgs, DARIO_DEFAULT_PORT } from "./installers/dario"; +import { resolveSpawnArgs as openwaSpawnArgs, OPENWA_DEFAULT_PORT } from "./installers/openwa"; import { getOrCreateApiKey } from "./apiKey"; import { scheduleServiceModelSync, stopServiceModelSync } from "./modelSync"; import type { ServiceStatus } from "./types"; @@ -34,6 +35,7 @@ const CLIPROXY_PORT = parseInt(process.env.CLIPROXYAPI_PORT ?? String(CLIPROXY_D const MUX_PORT = parseInt(process.env.MUX_SERVICE_PORT ?? String(MUX_DEFAULT_PORT), 10); const BIFROST_PORT = parseInt(process.env.BIFROST_PORT ?? String(BIFROST_DEFAULT_PORT), 10); const DARIO_PORT = parseInt(process.env.DARIO_PORT ?? String(DARIO_DEFAULT_PORT), 10); +const OPENWA_PORT = parseInt(process.env.OPENWA_SERVICE_PORT ?? String(OPENWA_DEFAULT_PORT), 10); type ServiceEntry = { tool: string; @@ -96,6 +98,39 @@ const SERVICES: ServiceEntry[] = [ logsBufferBytes: 5_242_880, needsApiKey: true, }, + { + // open-wa (@open-wa/wa-automate): WhatsApp Web automation via headless + // Chromium. Lifecycle-managed only — like Mux, it is not an LLM proxy and + // has no Layer 4 executor/provider entry. /api-docs/ (Swagger UI) is the + // only documented "proof of life" route for this package version; it + // only confirms the Express server answered, not that a WhatsApp session + // is paired (pairing status is surfaced via the logs panel — see + // installers/openwa.ts). + // + // Verified against the installed 4.76.0 source (dist/cli/index.js): the + // HTTP server does not call `server.listen()` until AFTER the full + // WhatsApp client handshake resolves — which, on first pairing, blocks on + // a human scanning the QR code shown in the logs panel. Every health + // probe before that point is a plain connection-refused, and + // HealthChecker's FAILURE_THRESHOLD (3, src/lib/services/healthCheck.ts) + // means the supervisor would otherwise declare "error" ~3×healthIntervalMs + // after every legitimate start — including a normal, successful one. + // healthIntervalMs is set high (vs. the 5s every other service uses) so + // that grace period (3×healthIntervalMs, ServiceSupervisor.waitForHealthy) + // is generous enough for a human to notice and scan the QR + // (~3 minutes) instead of always racing to "error". This is a + // ServiceSupervisor framework limitation (no separate "startup grace" + // knob distinct from the steady-state poll interval) — a real fix + // belongs in ServiceSupervisor/HealthChecker as a follow-up affecting + // all 5 services, not scoped here. + tool: "openwa", + port: OPENWA_PORT, + healthPath: "/api-docs/", + healthIntervalMs: 60_000, + stopTimeoutMs: 30_000, + logsBufferBytes: 5_242_880, + needsApiKey: true, + }, ]; function buildSpawnArgsFactory( @@ -114,6 +149,9 @@ function buildSpawnArgsFactory( if (cfg.tool === "dario") { return () => darioSpawnArgs(apiKey, cfg.port); } + if (cfg.tool === "openwa") { + return () => openwaSpawnArgs(apiKey, cfg.port); + } return () => cliproxySpawnArgs(cfg.port, apiKey); } diff --git a/src/lib/services/installers/openwa.ts b/src/lib/services/installers/openwa.ts new file mode 100644 index 0000000000..ec93c1b93e --- /dev/null +++ b/src/lib/services/installers/openwa.ts @@ -0,0 +1,227 @@ +/** + * open-wa (`@open-wa/wa-automate`) installer adapter for the ServiceSupervisor + * framework. + * + * open-wa (https://github.com/open-wa/wa-automate-nodejs) is a WhatsApp Web + * automation library published on npm as `@open-wa/wa-automate`, with a + * documented CLI server mode (`wa-automate --port `) that drives a + * headless Chromium instance via Puppeteer. It is lifecycle-managed only — + * like Mux, it is not an LLM proxy and has no Layer 4 executor/provider entry. + * + * It is installed the same way as every other embedded service — `npm + * install` into a DATA_DIR-scoped directory via `runNpm` (Hard Rule #13: no + * shell interpolation, array args + `env` option only) — never a git-clone + + * build. The install pulls in Puppeteer's own Chromium download, which is + * much larger/slower than a typical npm install, so a longer install timeout + * is used than the shared default. + * + * The library is unofficial and unaffiliated with WhatsApp (Hippocratic + + * Do No Harm license) — the connected number carries a ban risk from + * WhatsApp's own automation detection. That risk is surfaced in the + * dashboard tab, not enforced here. + * + * Binary location: $DATA_DIR/services/openwa/node_modules/@open-wa/wa-automate/bin/server.js + * Data dir: $DATA_DIR/services/openwa/data (Chromium profile / WhatsApp session) + * DB row: version_manager WHERE tool = 'openwa' + */ + +import fs from "node:fs"; +import path from "node:path"; +import { DATA_DIR } from "@/lib/db/core"; +import { upsertVersionManagerTool } from "@/lib/db/versionManager"; +import { runNpm, InstallError } from "./utils"; + +export const OPENWA_PACKAGE = "@open-wa/wa-automate"; +export const OPENWA_DEFAULT_PORT = 8323; +export const OPENWA_INSTALL_DIR = path.join(DATA_DIR, "services", "openwa"); + +// Puppeteer's own Chromium download routinely exceeds the shared 5-minute +// default install timeout (`DEFAULT_TIMEOUT_MS` in `./utils`). +const INSTALL_TIMEOUT_MS = 600_000; + +export interface InstallResult { + installedVersion: string; + installPath: string; + durationMs: number; +} + +export interface SpawnArgs { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + cwd: string; +} + +// In-memory latest-version cache, 1h TTL — mirrors mux.ts / ninerouter.ts. +let latestVersionCache: { value: string; expiresAt: number } | null = null; +const VERSION_CACHE_TTL_MS = 3_600_000; + +function getServerPath(): string { + return path.join( + OPENWA_INSTALL_DIR, + "node_modules", + "@open-wa", + "wa-automate", + "bin", + "server.js" + ); +} + +function getInstalledPkgPath(): string { + return path.join(OPENWA_INSTALL_DIR, "node_modules", "@open-wa", "wa-automate", "package.json"); +} + +export async function getInstalledVersion(): Promise { + try { + const raw = fs.readFileSync(getInstalledPkgPath(), "utf8"); + const parsed = JSON.parse(raw) as { version?: string }; + return typeof parsed.version === "string" ? parsed.version : null; + } catch { + return null; + } +} + +export async function getLatestVersion(): Promise { + if (latestVersionCache && latestVersionCache.expiresAt > Date.now()) { + return latestVersionCache.value; + } + try { + const { stdout } = await runNpm(["view", OPENWA_PACKAGE, "version"], { timeoutMs: 30_000 }); + const version = stdout.trim(); + if (version) { + latestVersionCache = { value: version, expiresAt: Date.now() + VERSION_CACHE_TTL_MS }; + } + return version || null; + } catch { + return null; + } +} + +/** + * Download and install open-wa from npm. + * Upserts the version_manager row with tool='openwa'. + */ +export async function install(version = "latest"): Promise { + const startMs = Date.now(); + + // Create install dir + minimal package.json (idempotent) — same shape as mux.ts. + fs.mkdirSync(OPENWA_INSTALL_DIR, { recursive: true }); + const hostPkgPath = path.join(OPENWA_INSTALL_DIR, "package.json"); + if (!fs.existsSync(hostPkgPath)) { + fs.writeFileSync( + hostPkgPath, + JSON.stringify( + { name: "omniroute-openwa-host", version: "0.0.0", private: true, dependencies: {} }, + null, + 2 + ), + "utf8" + ); + } + + await runNpm( + ["install", `${OPENWA_PACKAGE}@${version}`, "--omit=dev", "--no-audit", "--no-fund"], + // `--prefix` is passed via `prefix` (→ npm_config_prefix env) instead of an + // argv path so an install dir with spaces survives the Windows shell (#5379). + { cwd: OPENWA_INSTALL_DIR, prefix: OPENWA_INSTALL_DIR, timeoutMs: INSTALL_TIMEOUT_MS } + ); + + const installedVersion = await getInstalledVersion(); + if (!installedVersion) { + throw new InstallError( + "Could not read installed version from node_modules/@open-wa/wa-automate/package.json", + "open-wa instalado mas versão não pôde ser lida.", + 500 + ); + } + + await upsertVersionManagerTool({ + tool: "openwa", + installedVersion, + binaryPath: getServerPath(), + status: "stopped", + port: OPENWA_DEFAULT_PORT, + }); + + // Invalidate cache so next getLatestVersion() re-fetches + latestVersionCache = null; + + return { + installedVersion, + installPath: OPENWA_INSTALL_DIR, + durationMs: Date.now() - startMs, + }; +} + +export async function update(): Promise { + return install("latest"); +} + +export async function uninstall(): Promise { + const nmDir = path.join(OPENWA_INSTALL_DIR, "node_modules"); + if (fs.existsSync(nmDir)) { + fs.rmSync(nmDir, { recursive: true, force: true }); + } + await upsertVersionManagerTool({ + tool: "openwa", + status: "not_installed", + installedVersion: null, + binaryPath: null, + }); +} + +/** + * Build spawn args for ServiceSupervisor.start(). + * + * Flags/env verified directly against the installed 4.76.0 package + * (`dist/cli/cli-options.js`, `dist/cli/server.js`, `bin/config-schema.json`) + * rather than trusted from web docs, which mix this stable v4 line with an + * unreleased v5 alpha CLI surface: + * + * - `--host`/`-h` (default `"localhost"`) and `--port`/`-p` (default `8002`) + * set the bind address/port. open-wa binds to `127.0.0.1` explicitly (never + * `0.0.0.0`) — the dashboard route is already loopback-gated (Hard Rule + * #17), and this is defense-in-depth since a leaked port would expose + * direct WhatsApp send/receive control. + * - The API key is a generic `WA_*`-prefixed env override + * (`dist/cli/setup.js::envArgs()` maps any `WA_` env var onto the + * camelCased CLI option ``), so `WA_KEY` maps onto the `--key`/`-k` + * flag — never passed as a CLI flag, so it never appears in + * `ps`/process listings. open-wa's own auth middleware + * (`dist/cli/server.js::setupAuthenticationLayer`) reads it back from a + * `key` or `api_key` HTTP header (not `Authorization: Bearer`) and + * explicitly exempts `/api-docs*` — the health probe route — from the + * check, so no auth header is needed for `healthUrl`. + * - `--session-data-path` (env `WA_SESSION_DATA_PATH`, confirmed via + * `bin/config-schema.json`) is documented as "relative to the current + * working directory ... used to store and read your `.data.json` files" — + * passed as `"data"` (relative, per that documented usage) with `cwd` set + * to this service's DATA_DIR subfolder (like every other embedded + * service, instead of the OS user home directory), so the paired + * WhatsApp session survives restarts without a re-scanned QR code. + * - Headless is already the default (`--headful` defaults to `false`) — no + * flag needed. + */ +export function resolveSpawnArgs(apiKey: string, port: number): SpawnArgs { + const serverPath = getServerPath(); + fs.mkdirSync(path.join(OPENWA_INSTALL_DIR, "data"), { recursive: true }); + + return { + command: process.execPath, + args: [ + serverPath, + "--port", + String(port), + "--host", + "127.0.0.1", + "--session-data-path", + "data", + ], + env: { + ...process.env, + NODE_ENV: "production", + WA_KEY: apiKey, + }, + cwd: OPENWA_INSTALL_DIR, + }; +} diff --git a/tests/unit/services/installers/openwa.test.ts b/tests/unit/services/installers/openwa.test.ts new file mode 100644 index 0000000000..debcb4d06c --- /dev/null +++ b/tests/unit/services/installers/openwa.test.ts @@ -0,0 +1,167 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execSync } from "node:child_process"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-installer-")); +const FAKE_BIN_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-fake-bin-")); +const MOCK_OPENWA_VERSION = "4.76.0"; + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.NODE_ENV = "test"; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +// Prepend fake bin dir to PATH so our fake `npm` is found by runNpm +const originalPath = process.env.PATH ?? ""; +process.env.PATH = `${FAKE_BIN_DIR}:${originalPath}`; + +// Fake npm script: for `install` — creates expected package.json; +// for `view` — prints a version; for other args — no-op. +const fakeNpmScript = `#!/bin/sh +set -e +CMD="$1" +shift +if [ "$CMD" = "install" ]; then + # Resolve the install prefix like real npm: an explicit --prefix arg wins, + # otherwise fall back to the npm_config_prefix env var (#5379 passes the + # prefix via env instead of argv so paths with spaces survive the win shell). + PREFIX="" + while [ $# -gt 0 ]; do + if [ "$1" = "--prefix" ]; then PREFIX="$2"; shift 2; else shift; fi + done + if [ -z "$PREFIX" ]; then PREFIX="$npm_config_prefix"; fi + PKG_DIR="$PREFIX/node_modules/@open-wa/wa-automate" + mkdir -p "$PKG_DIR/bin" + echo '{"name":"@open-wa/wa-automate","version":"${MOCK_OPENWA_VERSION}"}' > "$PKG_DIR/package.json" + touch "$PKG_DIR/bin/server.js" + exit 0 +fi +if [ "$CMD" = "view" ]; then + echo "${MOCK_OPENWA_VERSION}" + exit 0 +fi +exit 0 +`; +const fakeNpmPath = path.join(FAKE_BIN_DIR, "npm"); +fs.writeFileSync(fakeNpmPath, fakeNpmScript, { mode: 0o755 }); + +// Verify fake npm is on PATH +execSync("which npm", { env: process.env }); + +// DB bootstrap (must be before openwa import due to db/core eager init) +const core = await import("../../../../src/lib/db/core.ts"); +const db = core.getDbInstance(); +db.prepare( + `INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose) + VALUES ('openwa', 'not_installed', 8323, 0, 0, 0)` +).run(); + +const { + install, + update, + uninstall, + getInstalledVersion, + getLatestVersion, + resolveSpawnArgs, + OPENWA_INSTALL_DIR, +} = await import("../../../../src/lib/services/installers/openwa.ts"); + +test.after(() => { + process.env.PATH = originalPath; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.rmSync(FAKE_BIN_DIR, { recursive: true, force: true }); +}); + +test("install creates package.json structure", async () => { + const result = await install(MOCK_OPENWA_VERSION); + + // Host package.json must exist + const hostPkg = path.join(OPENWA_INSTALL_DIR, "package.json"); + assert.ok(fs.existsSync(hostPkg), "host package.json should exist"); + const parsedHost = JSON.parse(fs.readFileSync(hostPkg, "utf8")) as { + name: string; + private: boolean; + }; + assert.equal(parsedHost.name, "omniroute-openwa-host"); + assert.ok(parsedHost.private); + + assert.equal(result.installedVersion, MOCK_OPENWA_VERSION); + assert.equal(result.installPath, OPENWA_INSTALL_DIR); + assert.ok(result.durationMs >= 0); +}); + +test("install captures real version from node_modules/@open-wa/wa-automate/package.json", async () => { + const ver = await getInstalledVersion(); + assert.equal(ver, MOCK_OPENWA_VERSION, "should read version from installed package"); +}); + +test("update calls npm install with latest (idempotent)", async () => { + const result = await update(); + assert.equal(result.installedVersion, MOCK_OPENWA_VERSION); +}); + +test("uninstall removes node_modules and marks not_installed in DB", async () => { + const nmDir = path.join(OPENWA_INSTALL_DIR, "node_modules"); + assert.ok(fs.existsSync(nmDir), "node_modules should exist before uninstall"); + + await uninstall(); + + assert.ok(!fs.existsSync(nmDir), "node_modules should be removed"); + + const { getVersionManagerTool } = await import("../../../../src/lib/db/versionManager.ts"); + const row = await getVersionManagerTool("openwa"); + assert.equal(row?.status, "not_installed"); + assert.equal(row?.installedVersion, null); + assert.equal(row?.binaryPath, null); + + // package.json host file should remain (preserves metadata) + assert.ok( + fs.existsSync(path.join(OPENWA_INSTALL_DIR, "package.json")), + "host package.json should be kept after uninstall" + ); +}); + +test("getLatestVersion returns version string from npm view", async () => { + const ver = await getLatestVersion(); + assert.equal(ver, MOCK_OPENWA_VERSION); +}); + +test("resolveSpawnArgs returns expected env, args and command", () => { + const args = resolveSpawnArgs("sk-test-api-key", 8323); + + assert.equal(args.command, process.execPath, "command must be current node binary"); + assert.ok(args.args[0]?.includes("server.js"), "args[0] should point to server.js"); + assert.deepEqual( + args.args.slice(1), + ["--port", "8323", "--host", "127.0.0.1", "--session-data-path", "data"], + "flags verified against the installed 4.76.0 dist/cli/cli-options.js + bin/config-schema.json" + ); + // The API key must ride the generic WA_*-prefixed env override (WA_KEY → + // camelCased "key" → the -k/--key CLI option), never argv, so it never + // appears in `ps`/process listings (Hard Rule #13). + assert.equal(args.env.WA_KEY, "sk-test-api-key"); + assert.equal(args.env.NODE_ENV, "production"); + assert.equal(args.cwd, OPENWA_INSTALL_DIR); +}); + +test("EACCES error returns friendly InstallError", async () => { + const { InstallError } = await import("../../../../src/lib/services/installers/utils.ts"); + + const err = new InstallError("raw error", "friendly message", 403); + assert.equal(err.httpStatus, 403); + assert.equal(err.friendly, "friendly message"); + assert.ok(err instanceof Error); + assert.equal(err.name, "InstallError"); +}); + +test("InstallError timeout shape has correct httpStatus 504", async () => { + const { InstallError: IE } = await import("../../../../src/lib/services/installers/utils.ts"); + const err = new IE("npm process killed", "Instalação demorou demais. Tente novamente.", 504); + assert.equal(err.httpStatus, 504); + assert.equal(err.friendly, "Instalação demorou demais. Tente novamente."); + assert.ok(err instanceof Error); + assert.equal(err.name, "InstallError"); +});