fix: improve onboarding allowlist + Control UI link

This commit is contained in:
Peter Steinberger
2026-01-02 10:57:04 +01:00
parent 71b0dcc922
commit 8bd5f1b9f2
5 changed files with 150 additions and 75 deletions

View File

@@ -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

View File

@@ -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.");
}

View File

@@ -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}`,
};
}

View File

@@ -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.");
}

View File

@@ -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<ClawdisConfig> {
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")) {