From 8bd5f1b9f28e08ed6e02e5c320608de482812fb1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 10:57:04 +0100 Subject: [PATCH] fix: improve onboarding allowlist + Control UI link --- CHANGELOG.md | 1 + src/commands/configure.ts | 25 +++++ src/commands/onboard-helpers.ts | 18 ++++ src/commands/onboard-interactive.ts | 29 ++++-- src/commands/onboard-providers.ts | 152 ++++++++++++++++------------ 5 files changed, 150 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be1b71838..2a43e1b0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ - CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup). - CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps. - CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard. +- CLI onboarding: always prompt for WhatsApp `routing.allowFrom` and print (optionally open) the Control UI URL when done. - CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode). - macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states. - macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 35f6d5967..c0096344f 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -39,6 +39,7 @@ import { printWizardHeader, probeGatewayReachable, randomToken, + resolveControlUiLinks, summarizeExistingConfig, } from "./onboard-helpers.js"; import { setupProviders } from "./onboard-providers.js"; @@ -550,6 +551,30 @@ export async function runConfigureWizard( } } + note( + (() => { + const bind = nextConfig.gateway?.bind ?? "loopback"; + const links = resolveControlUiLinks({ bind, port: gatewayPort }); + return [`Web UI: ${links.httpUrl}`, `Gateway WS: ${links.wsUrl}`].join( + "\n", + ); + })(), + "Control UI", + ); + + const wantsOpen = guardCancel( + await confirm({ + message: "Open Control UI now?", + initialValue: false, + }), + runtime, + ); + if (wantsOpen) { + const bind = nextConfig.gateway?.bind ?? "loopback"; + const links = resolveControlUiLinks({ bind, port: gatewayPort }); + await openUrl(links.httpUrl); + } + outro("Configure complete."); } diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 4e9b98ba5..258ff1575 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -12,6 +12,7 @@ import type { ClawdisConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDIS } from "../config/config.js"; import { resolveSessionTranscriptsDir } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; +import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import { runCommandWithTimeout } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; @@ -205,3 +206,20 @@ function summarizeError(err: unknown): string { } export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR; + +export function resolveControlUiLinks(params: { + port: number; + bind?: "auto" | "lan" | "tailnet" | "loopback"; +}): { httpUrl: string; wsUrl: string } { + const port = params.port; + const bind = params.bind ?? "loopback"; + const tailnetIPv4 = pickPrimaryTailnetIPv4(); + const host = + bind === "tailnet" || (bind === "auto" && tailnetIPv4) + ? (tailnetIPv4 ?? "127.0.0.1") + : "127.0.0.1"; + return { + httpUrl: `http://${host}:${port}/`, + wsUrl: `ws://${host}:${port}`, + }; +} diff --git a/src/commands/onboard-interactive.ts b/src/commands/onboard-interactive.ts index 758ddaf61..39f9c4a53 100644 --- a/src/commands/onboard-interactive.ts +++ b/src/commands/onboard-interactive.ts @@ -20,7 +20,6 @@ import { import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; -import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; @@ -40,6 +39,7 @@ import { printWizardHeader, probeGatewayReachable, randomToken, + resolveControlUiLinks, summarizeExistingConfig, } from "./onboard-helpers.js"; import { setupProviders } from "./onboard-providers.js"; @@ -481,18 +481,25 @@ export async function runInteractiveOnboarding( note( (() => { - const tailnetIPv4 = pickPrimaryTailnetIPv4(); - const host = - bind === "tailnet" || (bind === "auto" && tailnetIPv4) - ? (tailnetIPv4 ?? "127.0.0.1") - : "127.0.0.1"; - return [ - `Control UI: http://${host}:${port}/`, - `Gateway WS: ws://${host}:${port}`, - ].join("\n"); + const links = resolveControlUiLinks({ bind, port }); + return [`Web UI: ${links.httpUrl}`, `Gateway WS: ${links.wsUrl}`].join( + "\n", + ); })(), - "Open the Control UI", + "Control UI", ); + const wantsOpen = guardCancel( + await confirm({ + message: "Open Control UI now?", + initialValue: true, + }), + runtime, + ); + if (wantsOpen) { + const links = resolveControlUiLinks({ bind, port }); + await openUrl(links.httpUrl); + } + outro("Onboarding complete."); } diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index 1876d8dbe..43c63b44e 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -64,6 +64,93 @@ function noteDiscordTokenHelp(): void { ); } +function setRoutingAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) { + return { + ...cfg, + routing: { + ...(cfg.routing ?? {}), + allowFrom, + }, + }; +} + +async function promptWhatsAppAllowFrom( + cfg: ClawdisConfig, + runtime: RuntimeEnv, +): Promise { + const existingAllowFrom = cfg.routing?.allowFrom ?? []; + const existingLabel = + existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; + + note( + [ + "WhatsApp direct chats are gated by `routing.allowFrom`.", + 'Default (unset) = self-chat only; use "*" to allow anyone.', + `Current: ${existingLabel}`, + ].join("\n"), + "WhatsApp allowlist", + ); + + const options = + existingAllowFrom.length > 0 + ? ([ + { value: "keep", label: "Keep current" }, + { value: "self", label: "Self-chat only (unset)" }, + { value: "list", label: "Specific numbers (recommended)" }, + { value: "any", label: "Anyone (*)" }, + ] as const) + : ([ + { value: "self", label: "Self-chat only (default)" }, + { value: "list", label: "Specific numbers (recommended)" }, + { value: "any", label: "Anyone (*)" }, + ] as const); + + const mode = guardCancel( + await select({ + message: "Who can trigger the bot via WhatsApp?", + options: options.map((opt) => ({ value: opt.value, label: opt.label })), + }), + runtime, + ) as (typeof options)[number]["value"]; + + if (mode === "keep") return cfg; + if (mode === "self") return setRoutingAllowFrom(cfg, undefined); + if (mode === "any") return setRoutingAllowFrom(cfg, ["*"]); + + const allowRaw = guardCancel( + await text({ + message: "Allowed sender numbers (comma-separated, E.164)", + placeholder: "+15555550123, +447700900123", + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) return "Required"; + const parts = raw + .split(/[\n,;]+/g) + .map((p) => p.trim()) + .filter(Boolean); + if (parts.length === 0) return "Required"; + for (const part of parts) { + if (part === "*") continue; + const normalized = normalizeE164(part); + if (!normalized) return `Invalid number: ${part}`; + } + return undefined; + }, + }), + runtime, + ); + + const parts = String(allowRaw) + .split(/[\n,;]+/g) + .map((p) => p.trim()) + .filter(Boolean); + const normalized = parts.map((part) => + part === "*" ? "*" : normalizeE164(part), + ); + const unique = [...new Set(normalized.filter(Boolean))]; + return setRoutingAllowFrom(cfg, unique); +} + export async function setupProviders( cfg: ClawdisConfig, runtime: RuntimeEnv, @@ -198,70 +285,7 @@ export async function setupProviders( note("Run `clawdis login` later to link WhatsApp.", "WhatsApp"); } - const existingAllowFrom = cfg.routing?.allowFrom ?? []; - if (existingAllowFrom.length === 0) { - note( - [ - "WhatsApp direct chats are gated by `routing.allowFrom`.", - 'Default (unset) = self-chat only; use "*" to allow anyone.', - ].join("\n"), - "Allowlist (recommended)", - ); - const mode = guardCancel( - await select({ - message: "Who can trigger the bot via WhatsApp?", - options: [ - { value: "self", label: "Self-chat only (default)" }, - { value: "list", label: "Specific numbers (recommended)" }, - { value: "any", label: "Anyone (*)" }, - ], - }), - runtime, - ) as "self" | "list" | "any"; - - if (mode === "any") { - next = { - ...next, - routing: { ...next.routing, allowFrom: ["*"] }, - }; - } else if (mode === "list") { - const allowRaw = guardCancel( - await text({ - message: "Allowed sender numbers (comma-separated, E.164)", - placeholder: "+15555550123, +447700900123", - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) return "Required"; - const parts = raw - .split(/[\n,;]+/g) - .map((p) => p.trim()) - .filter(Boolean); - if (parts.length === 0) return "Required"; - for (const part of parts) { - if (part === "*") continue; - const normalized = normalizeE164(part); - if (!normalized) return `Invalid number: ${part}`; - } - return undefined; - }, - }), - runtime, - ); - - const parts = String(allowRaw) - .split(/[\n,;]+/g) - .map((p) => p.trim()) - .filter(Boolean); - const normalized = parts.map((part) => - part === "*" ? "*" : normalizeE164(part), - ); - const unique = [...new Set(normalized.filter(Boolean))]; - next = { - ...next, - routing: { ...next.routing, allowFrom: unique }, - }; - } - } + next = await promptWhatsAppAllowFrom(next, runtime); } if (selection.includes("telegram")) {