Files
openclaw/src/cli/daemon-cli.ts

1151 lines
35 KiB
TypeScript
Raw Normal View History

2026-01-07 21:37:05 +01:00
import path from "node:path";
import type { Command } from "commander";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
isGatewayDaemonRuntime,
} from "../commands/daemon-runtime.js";
import { resolveControlUiLinks } from "../commands/onboard-helpers.js";
import {
createConfigIO,
loadConfig,
resolveConfigPath,
resolveGatewayPort,
resolveStateDir,
} from "../config/config.js";
2026-01-07 21:37:05 +01:00
import { resolveIsNixMode } from "../config/paths.js";
import type {
BridgeBindMode,
GatewayControlUiConfig,
} from "../config/types.js";
2026-01-07 21:37:05 +01:00
import {
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
2026-01-07 21:37:05 +01:00
} from "../daemon/constants.js";
2026-01-08 08:24:28 +01:00
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
2026-01-07 21:37:05 +01:00
import {
type FindExtraGatewayServicesOptions,
findExtraGatewayServices,
2026-01-07 22:31:08 +01:00
renderGatewayServiceCleanupHints,
2026-01-07 21:37:05 +01:00
} from "../daemon/inspect.js";
2026-01-08 02:28:21 +01:00
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
2026-01-07 21:37:05 +01:00
import { findLegacyGatewayServices } from "../daemon/legacy.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
2026-01-12 08:33:28 +00:00
import {
renderSystemNodeWarning,
resolvePreferredNodePath,
resolveSystemNodeInfo,
} from "../daemon/runtime-paths.js";
2026-01-07 21:37:05 +01:00
import { resolveGatewayService } from "../daemon/service.js";
import type { ServiceConfigAudit } from "../daemon/service-audit.js";
import { auditGatewayServiceConfig } from "../daemon/service-audit.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
2026-01-07 21:37:05 +01:00
import { callGateway } from "../gateway/call.js";
import { resolveGatewayBindHost } from "../gateway/net.js";
2026-01-08 02:28:21 +01:00
import {
formatPortDiagnostics,
inspectPortUsage,
type PortListener,
type PortUsageStatus,
} from "../infra/ports.js";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
2026-01-08 06:48:28 +00:00
import { getResolvedLoggerSettings } from "../logging.js";
2026-01-07 21:37:05 +01:00
import { defaultRuntime } from "../runtime.js";
2026-01-10 20:50:17 +01:00
import { formatDocsLink } from "../terminal/links.js";
2026-01-09 03:22:02 +00:00
import { colorize, isRich, theme } from "../terminal/theme.js";
Move provider to a plugin-architecture (#661) * refactor: introduce provider plugin registry * refactor: move provider CLI to plugins * docs: add provider plugin implementation notes * refactor: shift provider runtime logic into plugins * refactor: add plugin defaults and summaries * docs: update provider plugin notes * feat(commands): add /commands slash list * Auto-reply: tidy help message * Auto-reply: fix status command lint * Tests: align google shared expectations * Auto-reply: tidy help message * Auto-reply: fix status command lint * refactor: move provider routing into plugins * test: align agent routing expectations * docs: update provider plugin notes * refactor: route replies via provider plugins * docs: note route-reply plugin hooks * refactor: extend provider plugin contract * refactor: derive provider status from plugins * refactor: unify gateway provider control * refactor: use plugin metadata in auto-reply * fix: parenthesize cron target selection * refactor: derive gateway methods from plugins * refactor: generalize provider logout * refactor: route provider logout through plugins * refactor: move WhatsApp web login methods into plugin * refactor: generalize provider log prefixes * refactor: centralize default chat provider * refactor: derive provider lists from registry * refactor: move provider reload noops into plugins * refactor: resolve web login provider via alias * refactor: derive CLI provider options from plugins * refactor: derive prompt provider list from plugins * style: apply biome lint fixes * fix: resolve provider routing edge cases * docs: update provider plugin refactor notes * fix(gateway): harden agent provider routing * refactor: move provider routing into plugins * refactor: move provider CLI to plugins * refactor: derive provider lists from registry * fix: restore slash command parsing * refactor: align provider ids for schema * refactor: unify outbound target resolution * fix: keep outbound labels stable * feat: add msteams to cron surfaces * fix: clean up lint build issues * refactor: localize chat provider alias normalization * refactor: drive gateway provider lists from plugins * docs: update provider plugin notes * style: format message-provider * fix: avoid provider registry init cycles * style: sort message-provider imports * fix: relax provider alias map typing * refactor: move provider routing into plugins * refactor: add plugin pairing/config adapters * refactor: route pairing and provider removal via plugins * refactor: align auto-reply provider typing * test: stabilize telegram media mocks * docs: update provider plugin refactor notes * refactor: pluginize outbound targets * refactor: pluginize provider selection * refactor: generalize text chunk limits * docs: update provider plugin notes * refactor: generalize group session/config * fix: normalize provider id for room detection * fix: avoid provider init in system prompt * style: formatting cleanup * refactor: normalize agent delivery targets * test: update outbound delivery labels * chore: fix lint regressions * refactor: extend provider plugin adapters * refactor: move elevated/block streaming defaults to plugins * refactor: defer outbound send deps to plugins * docs: note plugin-driven streaming/elevated defaults * refactor: centralize webchat provider constant * refactor: add provider setup adapters * refactor: delegate provider add config to plugins * docs: document plugin-driven provider add * refactor: add plugin state/binding metadata * refactor: build agent provider status from plugins * docs: note plugin-driven agent bindings * refactor: centralize internal provider constant usage * fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing) * refactor: centralize default chat provider * refactor: centralize WhatsApp target normalization * refactor: move provider routing into plugins * refactor: normalize agent delivery targets * chore: fix lint regressions * fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing) * feat: expand provider plugin adapters * refactor: route auto-reply via provider plugins * fix: align WhatsApp target normalization * fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing) * refactor: centralize WhatsApp target normalization * feat: add /config chat config updates * docs: add /config get alias * feat(commands): add /commands slash list * refactor: centralize default chat provider * style: apply biome lint fixes * chore: fix lint regressions * fix: clean up whatsapp allowlist typing * style: format config command helpers * refactor: pluginize tool threading context * refactor: normalize session announce targets * docs: note new plugin threading and announce hooks * refactor: pluginize message actions * docs: update provider plugin actions notes * fix: align provider action adapters * refactor: centralize webchat checks * style: format message provider helpers * refactor: move provider onboarding into adapters * docs: note onboarding provider adapters * feat: add msteams onboarding adapter * style: organize onboarding imports * fix: normalize msteams allowFrom types * feat: add plugin text chunk limits * refactor: use plugin chunk limit fallbacks * feat: add provider mention stripping hooks * style: organize provider plugin type imports * refactor: generalize health snapshots * refactor: update macOS health snapshot handling * docs: refresh health snapshot notes * style: format health snapshot updates * refactor: drive security warnings via plugins * docs: note provider security adapter * style: format provider security adapters * refactor: centralize provider account defaults * refactor: type gateway client identity constants * chore: regen gateway protocol swift * fix: degrade health on failed provider probe * refactor: centralize pairing approve hint * docs: add plugin CLI command references * refactor: route auth and tool sends through plugins * docs: expand provider plugin hooks * refactor: document provider docking touchpoints * refactor: normalize internal provider defaults * refactor: streamline outbound delivery wiring * refactor: make provider onboarding plugin-owned * refactor: support provider-owned agent tools * refactor: move telegram draft chunking into telegram module * refactor: infer provider tool sends via extractToolSend * fix: repair plugin onboarding imports * refactor: de-dup outbound target normalization * style: tidy plugin and agent imports * refactor: data-drive provider selection line * fix: satisfy lint after provider plugin rebase * test: deflake gateway-cli coverage * style: format gateway-cli coverage test * refactor(provider-plugins): simplify provider ids * test(pairing-cli): avoid provider-specific ternary * style(macos): swiftformat HealthStore * refactor(sandbox): derive provider tool denylist * fix(sandbox): avoid plugin init in defaults * refactor(provider-plugins): centralize provider aliases * style(test): satisfy biome * refactor(protocol): v3 providers.status maps * refactor(ui): adapt to protocol v3 * refactor(macos): adapt to protocol v3 * test: update providers.status v3 fixtures * refactor(gateway): map provider runtime snapshot * test(gateway): update reload runtime snapshot * refactor(whatsapp): normalize heartbeat provider id * docs(refactor): update provider plugin notes * style: satisfy biome after rebase * fix: describe sandboxed elevated in prompt * feat(gateway): add agent image attachments + live probe * refactor: derive CLI provider options from plugins * fix(gateway): harden agent provider routing * fix(gateway): harden agent provider routing * refactor: align provider ids for schema * fix(protocol): keep agent provider string * fix(gateway): harden agent provider routing * fix(protocol): keep agent provider string * refactor: normalize agent delivery targets * refactor: support provider-owned agent tools * refactor(config): provider-keyed elevated allowFrom * style: satisfy biome * fix(gateway): appease provider narrowing * style: satisfy biome * refactor(reply): move group intro hints into plugin * fix(reply): avoid plugin registry init cycle * refactor(providers): add lightweight provider dock * refactor(gateway): use typed client id in connect * refactor(providers): document docks and avoid init cycles * refactor(providers): make media limit helper generic * fix(providers): break plugin registry import cycles * style: satisfy biome * refactor(status-all): build providers table from plugins * refactor(gateway): delegate web login to provider plugin * refactor(provider): drop web alias * refactor(provider): lazy-load monitors * style: satisfy lint/format * style: format status-all providers table * style: swiftformat gateway discovery model * test: make reload plan plugin-driven * fix: avoid token stringification in status-all * refactor: make provider IDs explicit in status * feat: warn on signal/imessage provider runtime errors * test: cover gateway provider runtime warnings in status * fix: add runtime kind to provider status issues * test: cover health degradation on probe failure * fix: keep routeReply lightweight * style: organize routeReply imports * refactor(web): extract auth-store helpers * refactor(whatsapp): lazy login imports * refactor(outbound): route replies via plugin outbound * docs: update provider plugin notes * style: format provider status issues * fix: make sandbox scope warning wrap-safe * refactor: load outbound adapters from provider plugins * docs: update provider plugin outbound notes * style(macos): fix swiftformat lint * docs: changelog for provider plugins * fix(macos): satisfy swiftformat * fix(macos): open settings via menu action * style: format after rebase * fix(macos): open Settings via menu action --------- Co-authored-by: LK <luke@kyohere.com> Co-authored-by: Luke K (pr-0f3t) <2609441+lc0rp@users.noreply.github.com> Co-authored-by: Xin <xin@imfing.com>
2026-01-11 11:45:25 +00:00
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-channel.js";
2026-01-07 21:37:05 +01:00
import { createDefaultDeps } from "./deps.js";
import { withProgress } from "./progress.js";
2026-01-07 21:37:05 +01:00
type ConfigSummary = {
path: string;
exists: boolean;
valid: boolean;
issues?: Array<{ path: string; message: string }>;
controlUi?: GatewayControlUiConfig;
};
type GatewayStatusSummary = {
bindMode: BridgeBindMode;
feat: add Tailscale binary detection, IP binding modes, and health probe password fix This PR includes three main improvements: 1. Tailscale Binary Detection with Fallback Strategies - Added findTailscaleBinary() with multi-strategy detection: * PATH lookup via 'which' command * Known macOS app path (/Applications/Tailscale.app/Contents/MacOS/Tailscale) * find /Applications for Tailscale.app * locate database lookup - Added getTailscaleBinary() with caching - Updated all Tailscale operations to use detected binary - Added TUI warning when Tailscale binary not found for serve/funnel modes 2. Custom Gateway IP Binding with Fallback - New bind mode "custom" allowing user-specified IP with fallback to 0.0.0.0 - Removed "tailnet" mode (folded into "auto") - All modes now support graceful fallback: custom (if fail → 0.0.0.0), loopback (127.0.0.1 → 0.0.0.0), auto (tailnet → 0.0.0.0), lan (0.0.0.0) - Added customBindHost config option for custom bind mode - Added canBindTo() helper to test IP availability before binding - Updated configure and onboarding wizards with new bind mode options 3. Health Probe Password Auth Fix - Gateway probe now tries both new and old passwords - Fixes issue where password change fails health check if gateway hasn't restarted yet - Uses nextConfig password first, falls back to baseConfig password if needed Files changed: - src/infra/tailscale.ts: Binary detection + caching - src/gateway/net.ts: IP binding with fallback logic - src/config/types.ts: BridgeBindMode type + customBindHost field - src/commands/configure.ts: Health probe dual-password try + Tailscale detection warning + bind mode UI - src/wizard/onboarding.ts: Tailscale detection warning + bind mode UI - src/gateway/server.ts: Use new resolveGatewayBindHost - src/gateway/call.ts: Updated preferTailnet logic (removed "tailnet" mode) - src/commands/onboard-types.ts: Updated GatewayBind type - src/commands/onboard-helpers.ts: resolveControlUiLinks updated - src/cli/*.ts: Updated bind mode casts - src/gateway/call.test.ts: Removed "tailnet" mode test
2026-01-11 14:13:13 -06:00
bindHost: string;
customBindHost?: string;
port: number;
portSource: "service args" | "env/config";
probeUrl: string;
probeNote?: string;
};
2026-01-07 21:37:05 +01:00
type DaemonStatus = {
service: {
label: string;
loaded: boolean;
loadedText: string;
notLoadedText: string;
command?: {
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string>;
sourcePath?: string;
2026-01-07 21:37:05 +01:00
} | null;
2026-01-08 02:28:21 +01:00
runtime?: {
status?: string;
state?: string;
subState?: string;
pid?: number;
lastExitStatus?: number;
lastExitReason?: string;
lastRunResult?: string;
lastRunTime?: string;
detail?: string;
cachedLabel?: boolean;
missingUnit?: boolean;
};
configAudit?: ServiceConfigAudit;
2026-01-08 02:28:21 +01:00
};
config?: {
cli: ConfigSummary;
daemon?: ConfigSummary;
mismatch?: boolean;
};
gateway?: GatewayStatusSummary;
2026-01-08 02:28:21 +01:00
port?: {
port: number;
status: PortUsageStatus;
listeners: PortListener[];
hints: string[];
2026-01-07 21:37:05 +01:00
};
portCli?: {
port: number;
status: PortUsageStatus;
listeners: PortListener[];
hints: string[];
};
lastError?: string;
2026-01-07 21:37:05 +01:00
rpc?: {
ok: boolean;
error?: string;
url?: string;
2026-01-07 21:37:05 +01:00
};
legacyServices: Array<{ label: string; detail: string }>;
extraServices: Array<{ label: string; detail: string; scope: string }>;
};
export type GatewayRpcOpts = {
url?: string;
token?: string;
password?: string;
timeout?: string;
json?: boolean;
2026-01-07 21:37:05 +01:00
};
export type DaemonStatusOptions = {
rpc: GatewayRpcOpts;
probe: boolean;
json: boolean;
} & FindExtraGatewayServicesOptions;
export type DaemonInstallOptions = {
port?: string | number;
runtime?: string;
token?: string;
force?: boolean;
2026-01-07 21:37:05 +01:00
};
function parsePort(raw: unknown): number | null {
if (raw === undefined || raw === null) return null;
const value =
typeof raw === "string"
? raw
: typeof raw === "number" || typeof raw === "bigint"
? raw.toString()
: null;
if (value === null) return null;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return parsed;
}
function parsePortFromArgs(
programArguments: string[] | undefined,
): number | null {
if (!programArguments?.length) return null;
for (let i = 0; i < programArguments.length; i += 1) {
const arg = programArguments[i];
if (arg === "--port") {
const next = programArguments[i + 1];
const parsed = parsePort(next);
if (parsed) return parsed;
}
if (arg?.startsWith("--port=")) {
const parsed = parsePort(arg.split("=", 2)[1]);
if (parsed) return parsed;
}
}
return null;
}
2026-01-08 08:24:28 +01:00
function pickProbeHostForBind(
bindMode: string,
tailnetIPv4: string | undefined,
feat: add Tailscale binary detection, IP binding modes, and health probe password fix This PR includes three main improvements: 1. Tailscale Binary Detection with Fallback Strategies - Added findTailscaleBinary() with multi-strategy detection: * PATH lookup via 'which' command * Known macOS app path (/Applications/Tailscale.app/Contents/MacOS/Tailscale) * find /Applications for Tailscale.app * locate database lookup - Added getTailscaleBinary() with caching - Updated all Tailscale operations to use detected binary - Added TUI warning when Tailscale binary not found for serve/funnel modes 2. Custom Gateway IP Binding with Fallback - New bind mode "custom" allowing user-specified IP with fallback to 0.0.0.0 - Removed "tailnet" mode (folded into "auto") - All modes now support graceful fallback: custom (if fail → 0.0.0.0), loopback (127.0.0.1 → 0.0.0.0), auto (tailnet → 0.0.0.0), lan (0.0.0.0) - Added customBindHost config option for custom bind mode - Added canBindTo() helper to test IP availability before binding - Updated configure and onboarding wizards with new bind mode options 3. Health Probe Password Auth Fix - Gateway probe now tries both new and old passwords - Fixes issue where password change fails health check if gateway hasn't restarted yet - Uses nextConfig password first, falls back to baseConfig password if needed Files changed: - src/infra/tailscale.ts: Binary detection + caching - src/gateway/net.ts: IP binding with fallback logic - src/config/types.ts: BridgeBindMode type + customBindHost field - src/commands/configure.ts: Health probe dual-password try + Tailscale detection warning + bind mode UI - src/wizard/onboarding.ts: Tailscale detection warning + bind mode UI - src/gateway/server.ts: Use new resolveGatewayBindHost - src/gateway/call.ts: Updated preferTailnet logic (removed "tailnet" mode) - src/commands/onboard-types.ts: Updated GatewayBind type - src/commands/onboard-helpers.ts: resolveControlUiLinks updated - src/cli/*.ts: Updated bind mode casts - src/gateway/call.test.ts: Removed "tailnet" mode test
2026-01-11 14:13:13 -06:00
customBindHost?: string,
2026-01-08 08:24:28 +01:00
) {
feat: add Tailscale binary detection, IP binding modes, and health probe password fix This PR includes three main improvements: 1. Tailscale Binary Detection with Fallback Strategies - Added findTailscaleBinary() with multi-strategy detection: * PATH lookup via 'which' command * Known macOS app path (/Applications/Tailscale.app/Contents/MacOS/Tailscale) * find /Applications for Tailscale.app * locate database lookup - Added getTailscaleBinary() with caching - Updated all Tailscale operations to use detected binary - Added TUI warning when Tailscale binary not found for serve/funnel modes 2. Custom Gateway IP Binding with Fallback - New bind mode "custom" allowing user-specified IP with fallback to 0.0.0.0 - Removed "tailnet" mode (folded into "auto") - All modes now support graceful fallback: custom (if fail → 0.0.0.0), loopback (127.0.0.1 → 0.0.0.0), auto (tailnet → 0.0.0.0), lan (0.0.0.0) - Added customBindHost config option for custom bind mode - Added canBindTo() helper to test IP availability before binding - Updated configure and onboarding wizards with new bind mode options 3. Health Probe Password Auth Fix - Gateway probe now tries both new and old passwords - Fixes issue where password change fails health check if gateway hasn't restarted yet - Uses nextConfig password first, falls back to baseConfig password if needed Files changed: - src/infra/tailscale.ts: Binary detection + caching - src/gateway/net.ts: IP binding with fallback logic - src/config/types.ts: BridgeBindMode type + customBindHost field - src/commands/configure.ts: Health probe dual-password try + Tailscale detection warning + bind mode UI - src/wizard/onboarding.ts: Tailscale detection warning + bind mode UI - src/gateway/server.ts: Use new resolveGatewayBindHost - src/gateway/call.ts: Updated preferTailnet logic (removed "tailnet" mode) - src/commands/onboard-types.ts: Updated GatewayBind type - src/commands/onboard-helpers.ts: resolveControlUiLinks updated - src/cli/*.ts: Updated bind mode casts - src/gateway/call.test.ts: Removed "tailnet" mode test
2026-01-11 14:13:13 -06:00
if (bindMode === "custom" && customBindHost?.trim()) {
return customBindHost.trim();
}
if (bindMode === "auto") return tailnetIPv4 ?? "127.0.0.1";
return "127.0.0.1";
}
function safeDaemonEnv(env: Record<string, string> | undefined): string[] {
if (!env) return [];
const allow = [
"CLAWDBOT_PROFILE",
"CLAWDBOT_STATE_DIR",
"CLAWDBOT_CONFIG_PATH",
"CLAWDBOT_GATEWAY_PORT",
"CLAWDBOT_NIX_MODE",
];
const lines: string[] = [];
for (const key of allow) {
const value = env[key];
if (!value?.trim()) continue;
lines.push(`${key}=${value.trim()}`);
}
return lines;
}
function normalizeListenerAddress(raw: string): string {
let value = raw.trim();
if (!value) return value;
value = value.replace(/^TCP\s+/i, "");
value = value.replace(/\s+\(LISTEN\)\s*$/i, "");
return value.trim();
}
async function probeGatewayStatus(opts: {
url: string;
token?: string;
password?: string;
timeoutMs: number;
json?: boolean;
configPath?: string;
}) {
2026-01-07 21:37:05 +01:00
try {
await withProgress(
{
label: "Checking gateway status...",
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await callGateway({
url: opts.url,
token: opts.token,
password: opts.password,
method: "status",
timeoutMs: opts.timeoutMs,
Move provider to a plugin-architecture (#661) * refactor: introduce provider plugin registry * refactor: move provider CLI to plugins * docs: add provider plugin implementation notes * refactor: shift provider runtime logic into plugins * refactor: add plugin defaults and summaries * docs: update provider plugin notes * feat(commands): add /commands slash list * Auto-reply: tidy help message * Auto-reply: fix status command lint * Tests: align google shared expectations * Auto-reply: tidy help message * Auto-reply: fix status command lint * refactor: move provider routing into plugins * test: align agent routing expectations * docs: update provider plugin notes * refactor: route replies via provider plugins * docs: note route-reply plugin hooks * refactor: extend provider plugin contract * refactor: derive provider status from plugins * refactor: unify gateway provider control * refactor: use plugin metadata in auto-reply * fix: parenthesize cron target selection * refactor: derive gateway methods from plugins * refactor: generalize provider logout * refactor: route provider logout through plugins * refactor: move WhatsApp web login methods into plugin * refactor: generalize provider log prefixes * refactor: centralize default chat provider * refactor: derive provider lists from registry * refactor: move provider reload noops into plugins * refactor: resolve web login provider via alias * refactor: derive CLI provider options from plugins * refactor: derive prompt provider list from plugins * style: apply biome lint fixes * fix: resolve provider routing edge cases * docs: update provider plugin refactor notes * fix(gateway): harden agent provider routing * refactor: move provider routing into plugins * refactor: move provider CLI to plugins * refactor: derive provider lists from registry * fix: restore slash command parsing * refactor: align provider ids for schema * refactor: unify outbound target resolution * fix: keep outbound labels stable * feat: add msteams to cron surfaces * fix: clean up lint build issues * refactor: localize chat provider alias normalization * refactor: drive gateway provider lists from plugins * docs: update provider plugin notes * style: format message-provider * fix: avoid provider registry init cycles * style: sort message-provider imports * fix: relax provider alias map typing * refactor: move provider routing into plugins * refactor: add plugin pairing/config adapters * refactor: route pairing and provider removal via plugins * refactor: align auto-reply provider typing * test: stabilize telegram media mocks * docs: update provider plugin refactor notes * refactor: pluginize outbound targets * refactor: pluginize provider selection * refactor: generalize text chunk limits * docs: update provider plugin notes * refactor: generalize group session/config * fix: normalize provider id for room detection * fix: avoid provider init in system prompt * style: formatting cleanup * refactor: normalize agent delivery targets * test: update outbound delivery labels * chore: fix lint regressions * refactor: extend provider plugin adapters * refactor: move elevated/block streaming defaults to plugins * refactor: defer outbound send deps to plugins * docs: note plugin-driven streaming/elevated defaults * refactor: centralize webchat provider constant * refactor: add provider setup adapters * refactor: delegate provider add config to plugins * docs: document plugin-driven provider add * refactor: add plugin state/binding metadata * refactor: build agent provider status from plugins * docs: note plugin-driven agent bindings * refactor: centralize internal provider constant usage * fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing) * refactor: centralize default chat provider * refactor: centralize WhatsApp target normalization * refactor: move provider routing into plugins * refactor: normalize agent delivery targets * chore: fix lint regressions * fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing) * feat: expand provider plugin adapters * refactor: route auto-reply via provider plugins * fix: align WhatsApp target normalization * fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing) * refactor: centralize WhatsApp target normalization * feat: add /config chat config updates * docs: add /config get alias * feat(commands): add /commands slash list * refactor: centralize default chat provider * style: apply biome lint fixes * chore: fix lint regressions * fix: clean up whatsapp allowlist typing * style: format config command helpers * refactor: pluginize tool threading context * refactor: normalize session announce targets * docs: note new plugin threading and announce hooks * refactor: pluginize message actions * docs: update provider plugin actions notes * fix: align provider action adapters * refactor: centralize webchat checks * style: format message provider helpers * refactor: move provider onboarding into adapters * docs: note onboarding provider adapters * feat: add msteams onboarding adapter * style: organize onboarding imports * fix: normalize msteams allowFrom types * feat: add plugin text chunk limits * refactor: use plugin chunk limit fallbacks * feat: add provider mention stripping hooks * style: organize provider plugin type imports * refactor: generalize health snapshots * refactor: update macOS health snapshot handling * docs: refresh health snapshot notes * style: format health snapshot updates * refactor: drive security warnings via plugins * docs: note provider security adapter * style: format provider security adapters * refactor: centralize provider account defaults * refactor: type gateway client identity constants * chore: regen gateway protocol swift * fix: degrade health on failed provider probe * refactor: centralize pairing approve hint * docs: add plugin CLI command references * refactor: route auth and tool sends through plugins * docs: expand provider plugin hooks * refactor: document provider docking touchpoints * refactor: normalize internal provider defaults * refactor: streamline outbound delivery wiring * refactor: make provider onboarding plugin-owned * refactor: support provider-owned agent tools * refactor: move telegram draft chunking into telegram module * refactor: infer provider tool sends via extractToolSend * fix: repair plugin onboarding imports * refactor: de-dup outbound target normalization * style: tidy plugin and agent imports * refactor: data-drive provider selection line * fix: satisfy lint after provider plugin rebase * test: deflake gateway-cli coverage * style: format gateway-cli coverage test * refactor(provider-plugins): simplify provider ids * test(pairing-cli): avoid provider-specific ternary * style(macos): swiftformat HealthStore * refactor(sandbox): derive provider tool denylist * fix(sandbox): avoid plugin init in defaults * refactor(provider-plugins): centralize provider aliases * style(test): satisfy biome * refactor(protocol): v3 providers.status maps * refactor(ui): adapt to protocol v3 * refactor(macos): adapt to protocol v3 * test: update providers.status v3 fixtures * refactor(gateway): map provider runtime snapshot * test(gateway): update reload runtime snapshot * refactor(whatsapp): normalize heartbeat provider id * docs(refactor): update provider plugin notes * style: satisfy biome after rebase * fix: describe sandboxed elevated in prompt * feat(gateway): add agent image attachments + live probe * refactor: derive CLI provider options from plugins * fix(gateway): harden agent provider routing * fix(gateway): harden agent provider routing * refactor: align provider ids for schema * fix(protocol): keep agent provider string * fix(gateway): harden agent provider routing * fix(protocol): keep agent provider string * refactor: normalize agent delivery targets * refactor: support provider-owned agent tools * refactor(config): provider-keyed elevated allowFrom * style: satisfy biome * fix(gateway): appease provider narrowing * style: satisfy biome * refactor(reply): move group intro hints into plugin * fix(reply): avoid plugin registry init cycle * refactor(providers): add lightweight provider dock * refactor(gateway): use typed client id in connect * refactor(providers): document docks and avoid init cycles * refactor(providers): make media limit helper generic * fix(providers): break plugin registry import cycles * style: satisfy biome * refactor(status-all): build providers table from plugins * refactor(gateway): delegate web login to provider plugin * refactor(provider): drop web alias * refactor(provider): lazy-load monitors * style: satisfy lint/format * style: format status-all providers table * style: swiftformat gateway discovery model * test: make reload plan plugin-driven * fix: avoid token stringification in status-all * refactor: make provider IDs explicit in status * feat: warn on signal/imessage provider runtime errors * test: cover gateway provider runtime warnings in status * fix: add runtime kind to provider status issues * test: cover health degradation on probe failure * fix: keep routeReply lightweight * style: organize routeReply imports * refactor(web): extract auth-store helpers * refactor(whatsapp): lazy login imports * refactor(outbound): route replies via plugin outbound * docs: update provider plugin notes * style: format provider status issues * fix: make sandbox scope warning wrap-safe * refactor: load outbound adapters from provider plugins * docs: update provider plugin outbound notes * style(macos): fix swiftformat lint * docs: changelog for provider plugins * fix(macos): satisfy swiftformat * fix(macos): open settings via menu action * style: format after rebase * fix(macos): open Settings via menu action --------- Co-authored-by: LK <luke@kyohere.com> Co-authored-by: Luke K (pr-0f3t) <2609441+lc0rp@users.noreply.github.com> Co-authored-by: Xin <xin@imfing.com>
2026-01-11 11:45:25 +00:00
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
...(opts.configPath ? { configPath: opts.configPath } : {}),
}),
);
2026-01-07 21:37:05 +01:00
return { ok: true } as const;
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
} as const;
}
}
2026-01-08 02:28:21 +01:00
function formatRuntimeStatus(runtime: DaemonStatus["service"]["runtime"]) {
if (!runtime) return null;
const status = runtime.status ?? "unknown";
const details: string[] = [];
if (runtime.pid) details.push(`pid ${runtime.pid}`);
if (runtime.state && runtime.state.toLowerCase() !== status) {
details.push(`state ${runtime.state}`);
}
if (runtime.subState) details.push(`sub ${runtime.subState}`);
if (runtime.lastExitStatus !== undefined) {
details.push(`last exit ${runtime.lastExitStatus}`);
}
if (runtime.lastExitReason) {
details.push(`reason ${runtime.lastExitReason}`);
}
if (runtime.lastRunResult) {
details.push(`last run ${runtime.lastRunResult}`);
}
if (runtime.lastRunTime) {
details.push(`last run time ${runtime.lastRunTime}`);
}
if (runtime.detail) details.push(runtime.detail);
return details.length > 0 ? `${status} (${details.join(", ")})` : status;
}
function shouldReportPortUsage(
status: PortUsageStatus | undefined,
rpcOk?: boolean,
) {
if (status !== "busy") return false;
if (rpcOk === true) return false;
return true;
}
function renderRuntimeHints(
runtime: DaemonStatus["service"]["runtime"],
env: NodeJS.ProcessEnv = process.env,
2026-01-08 02:28:21 +01:00
): string[] {
if (!runtime) return [];
const hints: string[] = [];
2026-01-08 06:48:28 +00:00
const fileLog = (() => {
try {
return getResolvedLoggerSettings().file;
} catch {
return null;
}
})();
if (runtime.missingUnit) {
hints.push("Service not installed. Run: clawdbot daemon install");
if (fileLog) hints.push(`File logs: ${fileLog}`);
return hints;
}
2026-01-08 02:28:21 +01:00
if (runtime.status === "stopped") {
2026-01-08 06:48:28 +00:00
if (fileLog) hints.push(`File logs: ${fileLog}`);
2026-01-08 02:28:21 +01:00
if (process.platform === "darwin") {
const logs = resolveGatewayLogPaths(env);
2026-01-08 06:48:28 +00:00
hints.push(`Launchd stdout (if installed): ${logs.stdoutPath}`);
hints.push(`Launchd stderr (if installed): ${logs.stderrPath}`);
2026-01-08 02:28:21 +01:00
} else if (process.platform === "linux") {
const unit = resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
2026-01-08 02:28:21 +01:00
hints.push(
`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`,
2026-01-08 02:28:21 +01:00
);
} else if (process.platform === "win32") {
const task = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
hints.push(`Logs: schtasks /Query /TN "${task}" /V /FO LIST`);
2026-01-08 02:28:21 +01:00
}
}
return hints;
}
function renderGatewayServiceStartHints(
env: NodeJS.ProcessEnv = process.env,
): string[] {
2026-01-08 06:48:28 +00:00
const base = ["clawdbot daemon install", "clawdbot gateway"];
const profile = env.CLAWDBOT_PROFILE;
2026-01-07 21:37:05 +01:00
switch (process.platform) {
case "darwin": {
const label = resolveGatewayLaunchAgentLabel(profile);
2026-01-07 21:37:05 +01:00
return [
2026-01-08 06:48:28 +00:00
...base,
`launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${label}.plist`,
2026-01-07 21:37:05 +01:00
];
}
case "linux": {
const unit = resolveGatewaySystemdServiceName(profile);
2026-01-11 02:27:07 +01:00
return [...base, `systemctl --user start ${unit}.service`];
}
case "win32": {
const task = resolveGatewayWindowsTaskName(profile);
return [...base, `schtasks /Run /TN "${task}"`];
}
2026-01-07 21:37:05 +01:00
default:
2026-01-08 06:48:28 +00:00
return base;
2026-01-07 21:37:05 +01:00
}
}
async function gatherDaemonStatus(opts: {
rpc: GatewayRpcOpts;
probe: boolean;
deep?: boolean;
}): Promise<DaemonStatus> {
const service = resolveGatewayService();
2026-01-08 02:28:21 +01:00
const [loaded, command, runtime] = await Promise.all([
service
.isLoaded({
env: process.env,
profile: process.env.CLAWDBOT_PROFILE,
})
.catch(() => false),
2026-01-07 21:37:05 +01:00
service.readCommand(process.env).catch(() => null),
2026-01-08 02:28:21 +01:00
service.readRuntime(process.env).catch(() => undefined),
2026-01-07 21:37:05 +01:00
]);
const configAudit = await auditGatewayServiceConfig({
env: process.env,
command,
});
const serviceEnv = command?.environment ?? undefined;
const mergedDaemonEnv = {
...(process.env as Record<string, string | undefined>),
...(serviceEnv ?? undefined),
} satisfies Record<string, string | undefined>;
2026-01-08 08:24:28 +01:00
const cliConfigPath = resolveConfigPath(
process.env,
resolveStateDir(process.env),
);
const daemonConfigPath = resolveConfigPath(
mergedDaemonEnv as NodeJS.ProcessEnv,
resolveStateDir(mergedDaemonEnv as NodeJS.ProcessEnv),
);
const cliIO = createConfigIO({ env: process.env, configPath: cliConfigPath });
const daemonIO = createConfigIO({
env: mergedDaemonEnv,
configPath: daemonConfigPath,
});
const [cliSnapshot, daemonSnapshot] = await Promise.all([
cliIO.readConfigFileSnapshot().catch(() => null),
daemonIO.readConfigFileSnapshot().catch(() => null),
]);
const cliCfg = cliIO.loadConfig();
const daemonCfg = daemonIO.loadConfig();
const cliConfigSummary: ConfigSummary = {
path: cliSnapshot?.path ?? cliConfigPath,
exists: cliSnapshot?.exists ?? false,
valid: cliSnapshot?.valid ?? true,
...(cliSnapshot?.issues?.length ? { issues: cliSnapshot.issues } : {}),
controlUi: cliCfg.gateway?.controlUi,
};
const daemonConfigSummary: ConfigSummary = {
path: daemonSnapshot?.path ?? daemonConfigPath,
exists: daemonSnapshot?.exists ?? false,
valid: daemonSnapshot?.valid ?? true,
2026-01-08 08:24:28 +01:00
...(daemonSnapshot?.issues?.length
? { issues: daemonSnapshot.issues }
: {}),
controlUi: daemonCfg.gateway?.controlUi,
};
const configMismatch = cliConfigSummary.path !== daemonConfigSummary.path;
const portFromArgs = parsePortFromArgs(command?.programArguments);
2026-01-08 08:24:28 +01:00
const daemonPort =
portFromArgs ?? resolveGatewayPort(daemonCfg, mergedDaemonEnv);
const portSource: GatewayStatusSummary["portSource"] = portFromArgs
? "service args"
: "env/config";
const bindMode = (daemonCfg.gateway?.bind ?? "loopback") as
| "auto"
| "lan"
feat: add Tailscale binary detection, IP binding modes, and health probe password fix This PR includes three main improvements: 1. Tailscale Binary Detection with Fallback Strategies - Added findTailscaleBinary() with multi-strategy detection: * PATH lookup via 'which' command * Known macOS app path (/Applications/Tailscale.app/Contents/MacOS/Tailscale) * find /Applications for Tailscale.app * locate database lookup - Added getTailscaleBinary() with caching - Updated all Tailscale operations to use detected binary - Added TUI warning when Tailscale binary not found for serve/funnel modes 2. Custom Gateway IP Binding with Fallback - New bind mode "custom" allowing user-specified IP with fallback to 0.0.0.0 - Removed "tailnet" mode (folded into "auto") - All modes now support graceful fallback: custom (if fail → 0.0.0.0), loopback (127.0.0.1 → 0.0.0.0), auto (tailnet → 0.0.0.0), lan (0.0.0.0) - Added customBindHost config option for custom bind mode - Added canBindTo() helper to test IP availability before binding - Updated configure and onboarding wizards with new bind mode options 3. Health Probe Password Auth Fix - Gateway probe now tries both new and old passwords - Fixes issue where password change fails health check if gateway hasn't restarted yet - Uses nextConfig password first, falls back to baseConfig password if needed Files changed: - src/infra/tailscale.ts: Binary detection + caching - src/gateway/net.ts: IP binding with fallback logic - src/config/types.ts: BridgeBindMode type + customBindHost field - src/commands/configure.ts: Health probe dual-password try + Tailscale detection warning + bind mode UI - src/wizard/onboarding.ts: Tailscale detection warning + bind mode UI - src/gateway/server.ts: Use new resolveGatewayBindHost - src/gateway/call.ts: Updated preferTailnet logic (removed "tailnet" mode) - src/commands/onboard-types.ts: Updated GatewayBind type - src/commands/onboard-helpers.ts: resolveControlUiLinks updated - src/cli/*.ts: Updated bind mode casts - src/gateway/call.test.ts: Removed "tailnet" mode test
2026-01-11 14:13:13 -06:00
| "loopback"
| "custom";
const customBindHost = daemonCfg.gateway?.customBindHost;
const bindHost = await resolveGatewayBindHost(bindMode, customBindHost);
const tailnetIPv4 = pickPrimaryTailnetIPv4();
feat: add Tailscale binary detection, IP binding modes, and health probe password fix This PR includes three main improvements: 1. Tailscale Binary Detection with Fallback Strategies - Added findTailscaleBinary() with multi-strategy detection: * PATH lookup via 'which' command * Known macOS app path (/Applications/Tailscale.app/Contents/MacOS/Tailscale) * find /Applications for Tailscale.app * locate database lookup - Added getTailscaleBinary() with caching - Updated all Tailscale operations to use detected binary - Added TUI warning when Tailscale binary not found for serve/funnel modes 2. Custom Gateway IP Binding with Fallback - New bind mode "custom" allowing user-specified IP with fallback to 0.0.0.0 - Removed "tailnet" mode (folded into "auto") - All modes now support graceful fallback: custom (if fail → 0.0.0.0), loopback (127.0.0.1 → 0.0.0.0), auto (tailnet → 0.0.0.0), lan (0.0.0.0) - Added customBindHost config option for custom bind mode - Added canBindTo() helper to test IP availability before binding - Updated configure and onboarding wizards with new bind mode options 3. Health Probe Password Auth Fix - Gateway probe now tries both new and old passwords - Fixes issue where password change fails health check if gateway hasn't restarted yet - Uses nextConfig password first, falls back to baseConfig password if needed Files changed: - src/infra/tailscale.ts: Binary detection + caching - src/gateway/net.ts: IP binding with fallback logic - src/config/types.ts: BridgeBindMode type + customBindHost field - src/commands/configure.ts: Health probe dual-password try + Tailscale detection warning + bind mode UI - src/wizard/onboarding.ts: Tailscale detection warning + bind mode UI - src/gateway/server.ts: Use new resolveGatewayBindHost - src/gateway/call.ts: Updated preferTailnet logic (removed "tailnet" mode) - src/commands/onboard-types.ts: Updated GatewayBind type - src/commands/onboard-helpers.ts: resolveControlUiLinks updated - src/cli/*.ts: Updated bind mode casts - src/gateway/call.test.ts: Removed "tailnet" mode test
2026-01-11 14:13:13 -06:00
const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4, customBindHost);
const probeUrlOverride =
typeof opts.rpc.url === "string" && opts.rpc.url.trim().length > 0
? opts.rpc.url.trim()
: null;
const probeUrl = probeUrlOverride ?? `ws://${probeHost}:${daemonPort}`;
const probeNote =
!probeUrlOverride && bindMode === "lan"
? "Local probe uses loopback (127.0.0.1). bind=lan listens on 0.0.0.0 (all interfaces); use a LAN IP for remote clients."
: !probeUrlOverride && bindMode === "loopback"
? "Loopback-only gateway; only local clients can connect."
: undefined;
const cliPort = resolveGatewayPort(cliCfg, process.env);
const [portDiagnostics, portCliDiagnostics] = await Promise.all([
inspectPortUsage(daemonPort).catch(() => null),
cliPort !== daemonPort ? inspectPortUsage(cliPort).catch(() => null) : null,
]);
const portStatus: DaemonStatus["port"] | undefined = portDiagnostics
? {
port: portDiagnostics.port,
status: portDiagnostics.status,
listeners: portDiagnostics.listeners,
hints: portDiagnostics.hints,
}
: undefined;
const portCliStatus: DaemonStatus["portCli"] | undefined = portCliDiagnostics
? {
port: portCliDiagnostics.port,
status: portCliDiagnostics.status,
listeners: portCliDiagnostics.listeners,
hints: portCliDiagnostics.hints,
}
: undefined;
2026-01-07 21:37:05 +01:00
const legacyServices = await findLegacyGatewayServices(process.env);
const extraServices = await findExtraGatewayServices(process.env, {
deep: opts.deep,
});
const timeoutMsRaw = Number.parseInt(String(opts.rpc.timeout ?? "10000"), 10);
const timeoutMs =
Number.isFinite(timeoutMsRaw) && timeoutMsRaw > 0 ? timeoutMsRaw : 10_000;
const rpc = opts.probe
? await probeGatewayStatus({
url: probeUrl,
token:
opts.rpc.token ||
mergedDaemonEnv.CLAWDBOT_GATEWAY_TOKEN ||
daemonCfg.gateway?.auth?.token,
password:
opts.rpc.password ||
mergedDaemonEnv.CLAWDBOT_GATEWAY_PASSWORD ||
daemonCfg.gateway?.auth?.password,
timeoutMs,
json: opts.rpc.json,
configPath: daemonConfigSummary.path,
})
: undefined;
let lastError: string | undefined;
if (
loaded &&
runtime?.status === "running" &&
portStatus &&
portStatus.status !== "busy"
) {
lastError =
(await readLastGatewayErrorLine(mergedDaemonEnv as NodeJS.ProcessEnv)) ??
undefined;
}
2026-01-07 21:37:05 +01:00
return {
service: {
label: service.label,
loaded,
loadedText: service.loadedText,
notLoadedText: service.notLoadedText,
command,
2026-01-08 02:28:21 +01:00
runtime,
configAudit,
2026-01-07 21:37:05 +01:00
},
config: {
cli: cliConfigSummary,
daemon: daemonConfigSummary,
...(configMismatch ? { mismatch: true } : {}),
},
gateway: {
bindMode,
bindHost,
feat: add Tailscale binary detection, IP binding modes, and health probe password fix This PR includes three main improvements: 1. Tailscale Binary Detection with Fallback Strategies - Added findTailscaleBinary() with multi-strategy detection: * PATH lookup via 'which' command * Known macOS app path (/Applications/Tailscale.app/Contents/MacOS/Tailscale) * find /Applications for Tailscale.app * locate database lookup - Added getTailscaleBinary() with caching - Updated all Tailscale operations to use detected binary - Added TUI warning when Tailscale binary not found for serve/funnel modes 2. Custom Gateway IP Binding with Fallback - New bind mode "custom" allowing user-specified IP with fallback to 0.0.0.0 - Removed "tailnet" mode (folded into "auto") - All modes now support graceful fallback: custom (if fail → 0.0.0.0), loopback (127.0.0.1 → 0.0.0.0), auto (tailnet → 0.0.0.0), lan (0.0.0.0) - Added customBindHost config option for custom bind mode - Added canBindTo() helper to test IP availability before binding - Updated configure and onboarding wizards with new bind mode options 3. Health Probe Password Auth Fix - Gateway probe now tries both new and old passwords - Fixes issue where password change fails health check if gateway hasn't restarted yet - Uses nextConfig password first, falls back to baseConfig password if needed Files changed: - src/infra/tailscale.ts: Binary detection + caching - src/gateway/net.ts: IP binding with fallback logic - src/config/types.ts: BridgeBindMode type + customBindHost field - src/commands/configure.ts: Health probe dual-password try + Tailscale detection warning + bind mode UI - src/wizard/onboarding.ts: Tailscale detection warning + bind mode UI - src/gateway/server.ts: Use new resolveGatewayBindHost - src/gateway/call.ts: Updated preferTailnet logic (removed "tailnet" mode) - src/commands/onboard-types.ts: Updated GatewayBind type - src/commands/onboard-helpers.ts: resolveControlUiLinks updated - src/cli/*.ts: Updated bind mode casts - src/gateway/call.test.ts: Removed "tailnet" mode test
2026-01-11 14:13:13 -06:00
customBindHost,
port: daemonPort,
portSource,
probeUrl,
...(probeNote ? { probeNote } : {}),
},
2026-01-08 02:28:21 +01:00
port: portStatus,
...(portCliStatus ? { portCli: portCliStatus } : {}),
lastError,
...(rpc ? { rpc: { ...rpc, url: probeUrl } } : {}),
2026-01-07 21:37:05 +01:00
legacyServices,
extraServices,
};
}
function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
if (opts.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
return;
}
2026-01-09 03:22:02 +00:00
const rich = isRich();
const label = (value: string) => colorize(rich, theme.muted, value);
const accent = (value: string) => colorize(rich, theme.accent, value);
const infoText = (value: string) => colorize(rich, theme.info, value);
const okText = (value: string) => colorize(rich, theme.success, value);
const warnText = (value: string) => colorize(rich, theme.warn, value);
const errorText = (value: string) => colorize(rich, theme.error, value);
const spacer = () => defaultRuntime.log("");
2026-01-09 03:22:02 +00:00
2026-01-07 21:37:05 +01:00
const { service, rpc, legacyServices, extraServices } = status;
2026-01-09 03:22:02 +00:00
const serviceStatus = service.loaded
? okText(service.loadedText)
: warnText(service.notLoadedText);
2026-01-07 21:37:05 +01:00
defaultRuntime.log(
2026-01-09 03:22:02 +00:00
`${label("Service:")} ${accent(service.label)} (${serviceStatus})`,
2026-01-07 21:37:05 +01:00
);
2026-01-08 06:48:28 +00:00
try {
const logFile = getResolvedLoggerSettings().file;
2026-01-09 03:22:02 +00:00
defaultRuntime.log(`${label("File logs:")} ${infoText(logFile)}`);
2026-01-08 06:48:28 +00:00
} catch {
// ignore missing config/log resolution
}
2026-01-07 21:37:05 +01:00
if (service.command?.programArguments?.length) {
defaultRuntime.log(
2026-01-09 03:22:02 +00:00
`${label("Command:")} ${infoText(service.command.programArguments.join(" "))}`,
2026-01-07 21:37:05 +01:00
);
}
if (service.command?.sourcePath) {
2026-01-09 03:22:02 +00:00
defaultRuntime.log(
`${label("Service file:")} ${infoText(service.command.sourcePath)}`,
);
}
2026-01-07 21:37:05 +01:00
if (service.command?.workingDirectory) {
2026-01-09 03:22:02 +00:00
defaultRuntime.log(
`${label("Working dir:")} ${infoText(service.command.workingDirectory)}`,
);
2026-01-07 21:37:05 +01:00
}
const daemonEnvLines = safeDaemonEnv(service.command?.environment);
if (daemonEnvLines.length > 0) {
2026-01-09 03:22:02 +00:00
defaultRuntime.log(`${label("Daemon env:")} ${daemonEnvLines.join(" ")}`);
}
spacer();
if (service.configAudit?.issues.length) {
2026-01-09 03:22:02 +00:00
defaultRuntime.error(
warnText("Service config looks out of date or non-standard."),
);
for (const issue of service.configAudit.issues) {
const detail = issue.detail ? ` (${issue.detail})` : "";
2026-01-09 03:22:02 +00:00
defaultRuntime.error(
`${warnText("Service config issue:")} ${issue.message}${detail}`,
);
}
2026-01-08 21:47:35 +01:00
defaultRuntime.error(
2026-01-09 03:22:02 +00:00
warnText(
'Recommendation: run "clawdbot doctor" (or "clawdbot doctor --repair").',
),
2026-01-08 21:47:35 +01:00
);
}
if (status.config) {
const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;
2026-01-09 03:22:02 +00:00
defaultRuntime.log(`${label("Config (cli):")} ${infoText(cliCfg)}`);
if (!status.config.cli.valid && status.config.cli.issues?.length) {
for (const issue of status.config.cli.issues.slice(0, 5)) {
2026-01-08 08:24:28 +01:00
defaultRuntime.error(
2026-01-09 03:22:02 +00:00
`${errorText("Config issue:")} ${issue.path || "<root>"}: ${issue.message}`,
2026-01-08 08:24:28 +01:00
);
}
}
if (status.config.daemon) {
const daemonCfg = `${status.config.daemon.path}${status.config.daemon.exists ? "" : " (missing)"}${status.config.daemon.valid ? "" : " (invalid)"}`;
2026-01-09 03:22:02 +00:00
defaultRuntime.log(`${label("Config (daemon):")} ${infoText(daemonCfg)}`);
if (!status.config.daemon.valid && status.config.daemon.issues?.length) {
for (const issue of status.config.daemon.issues.slice(0, 5)) {
defaultRuntime.error(
2026-01-09 03:22:02 +00:00
`${errorText("Daemon config issue:")} ${issue.path || "<root>"}: ${issue.message}`,
);
}
}
}
if (status.config.mismatch) {
defaultRuntime.error(
2026-01-09 03:22:02 +00:00
errorText(
"Root cause: CLI and daemon are using different config paths (likely a profile/state-dir mismatch).",
),
);
defaultRuntime.error(
2026-01-09 03:22:02 +00:00
errorText(
"Fix: rerun `clawdbot daemon install --force` from the same --profile / CLAWDBOT_STATE_DIR you expect.",
),
);
}
spacer();
}
if (status.gateway) {
const bindHost = status.gateway.bindHost ?? "n/a";
defaultRuntime.log(
2026-01-09 03:22:02 +00:00
`${label("Gateway:")} bind=${infoText(status.gateway.bindMode)} (${infoText(bindHost)}), port=${infoText(String(status.gateway.port))} (${infoText(status.gateway.portSource)})`,
);
defaultRuntime.log(
`${label("Probe target:")} ${infoText(status.gateway.probeUrl)}`,
);
2026-01-08 10:51:55 +01:00
const controlUiEnabled = status.config?.daemon?.controlUi?.enabled ?? true;
if (!controlUiEnabled) {
2026-01-09 03:22:02 +00:00
defaultRuntime.log(`${label("Dashboard:")} ${warnText("disabled")}`);
2026-01-08 10:51:55 +01:00
} else {
const links = resolveControlUiLinks({
port: status.gateway.port,
bind: status.gateway.bindMode,
feat: add Tailscale binary detection, IP binding modes, and health probe password fix This PR includes three main improvements: 1. Tailscale Binary Detection with Fallback Strategies - Added findTailscaleBinary() with multi-strategy detection: * PATH lookup via 'which' command * Known macOS app path (/Applications/Tailscale.app/Contents/MacOS/Tailscale) * find /Applications for Tailscale.app * locate database lookup - Added getTailscaleBinary() with caching - Updated all Tailscale operations to use detected binary - Added TUI warning when Tailscale binary not found for serve/funnel modes 2. Custom Gateway IP Binding with Fallback - New bind mode "custom" allowing user-specified IP with fallback to 0.0.0.0 - Removed "tailnet" mode (folded into "auto") - All modes now support graceful fallback: custom (if fail → 0.0.0.0), loopback (127.0.0.1 → 0.0.0.0), auto (tailnet → 0.0.0.0), lan (0.0.0.0) - Added customBindHost config option for custom bind mode - Added canBindTo() helper to test IP availability before binding - Updated configure and onboarding wizards with new bind mode options 3. Health Probe Password Auth Fix - Gateway probe now tries both new and old passwords - Fixes issue where password change fails health check if gateway hasn't restarted yet - Uses nextConfig password first, falls back to baseConfig password if needed Files changed: - src/infra/tailscale.ts: Binary detection + caching - src/gateway/net.ts: IP binding with fallback logic - src/config/types.ts: BridgeBindMode type + customBindHost field - src/commands/configure.ts: Health probe dual-password try + Tailscale detection warning + bind mode UI - src/wizard/onboarding.ts: Tailscale detection warning + bind mode UI - src/gateway/server.ts: Use new resolveGatewayBindHost - src/gateway/call.ts: Updated preferTailnet logic (removed "tailnet" mode) - src/commands/onboard-types.ts: Updated GatewayBind type - src/commands/onboard-helpers.ts: resolveControlUiLinks updated - src/cli/*.ts: Updated bind mode casts - src/gateway/call.test.ts: Removed "tailnet" mode test
2026-01-11 14:13:13 -06:00
customBindHost: status.gateway.customBindHost,
2026-01-08 10:51:55 +01:00
basePath: status.config?.daemon?.controlUi?.basePath,
});
2026-01-09 03:22:02 +00:00
defaultRuntime.log(`${label("Dashboard:")} ${infoText(links.httpUrl)}`);
2026-01-08 10:51:55 +01:00
}
if (status.gateway.probeNote) {
2026-01-09 03:22:02 +00:00
defaultRuntime.log(
`${label("Probe note:")} ${infoText(status.gateway.probeNote)}`,
);
}
spacer();
}
2026-01-08 02:28:21 +01:00
const runtimeLine = formatRuntimeStatus(service.runtime);
if (runtimeLine) {
2026-01-09 03:22:02 +00:00
const runtimeStatus = service.runtime?.status ?? "unknown";
const runtimeColor =
runtimeStatus === "running"
? theme.success
: runtimeStatus === "stopped"
? theme.error
: runtimeStatus === "unknown"
? theme.muted
: theme.warn;
defaultRuntime.log(
`${label("Runtime:")} ${colorize(rich, runtimeColor, runtimeLine)}`,
);
2026-01-08 02:28:21 +01:00
}
if (
rpc &&
!rpc.ok &&
service.loaded &&
service.runtime?.status === "running"
) {
2026-01-08 10:29:44 +01:00
defaultRuntime.log(
2026-01-09 05:27:54 +00:00
warnText(
"Warm-up: launch agents can take a few seconds. Try again shortly.",
),
2026-01-08 10:29:44 +01:00
);
}
2026-01-07 21:37:05 +01:00
if (rpc) {
if (rpc.ok) {
2026-01-09 03:22:02 +00:00
defaultRuntime.log(`${label("RPC probe:")} ${okText("ok")}`);
2026-01-07 21:37:05 +01:00
} else {
2026-01-09 03:22:02 +00:00
defaultRuntime.error(`${label("RPC probe:")} ${errorText("failed")}`);
2026-01-09 05:27:54 +00:00
if (rpc.url) defaultRuntime.error(`${label("RPC target:")} ${rpc.url}`);
2026-01-08 08:24:28 +01:00
const lines = String(rpc.error ?? "unknown")
.split(/\r?\n/)
.filter(Boolean);
for (const line of lines.slice(0, 12)) {
2026-01-09 03:22:02 +00:00
defaultRuntime.error(` ${errorText(line)}`);
}
2026-01-07 21:37:05 +01:00
}
spacer();
2026-01-07 21:37:05 +01:00
}
2026-01-08 06:48:28 +00:00
if (service.runtime?.missingUnit) {
2026-01-09 03:22:02 +00:00
defaultRuntime.error(errorText("Service unit not found."));
2026-01-08 06:48:28 +00:00
for (const hint of renderRuntimeHints(service.runtime)) {
2026-01-09 03:22:02 +00:00
defaultRuntime.error(errorText(hint));
2026-01-08 06:48:28 +00:00
}
} else if (service.loaded && service.runtime?.status === "stopped") {
2026-01-08 02:28:21 +01:00
defaultRuntime.error(
2026-01-09 05:27:54 +00:00
errorText(
"Service is loaded but not running (likely exited immediately).",
),
2026-01-08 02:28:21 +01:00
);
for (const hint of renderRuntimeHints(
service.runtime,
(service.command?.environment ?? process.env) as NodeJS.ProcessEnv,
)) {
2026-01-09 03:22:02 +00:00
defaultRuntime.error(errorText(hint));
2026-01-08 02:28:21 +01:00
}
spacer();
2026-01-08 02:28:21 +01:00
}
if (service.runtime?.cachedLabel) {
2026-01-11 02:27:07 +01:00
const env = (service.command?.environment ??
process.env) as NodeJS.ProcessEnv;
const label = resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
2026-01-08 02:28:21 +01:00
defaultRuntime.error(
2026-01-09 03:22:02 +00:00
errorText(
`LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${label}`,
2026-01-09 03:22:02 +00:00
),
2026-01-08 02:28:21 +01:00
);
2026-01-09 03:22:02 +00:00
defaultRuntime.error(errorText("Then reinstall: clawdbot daemon install"));
spacer();
2026-01-08 02:28:21 +01:00
}
if (status.port && shouldReportPortUsage(status.port.status, rpc?.ok)) {
for (const line of formatPortDiagnostics({
port: status.port.port,
status: status.port.status,
listeners: status.port.listeners,
hints: status.port.hints,
})) {
2026-01-09 03:22:02 +00:00
defaultRuntime.error(errorText(line));
2026-01-08 02:28:21 +01:00
}
}
if (status.port) {
const addrs = Array.from(
new Set(
status.port.listeners
.map((l) => (l.address ? normalizeListenerAddress(l.address) : ""))
.filter((v): v is string => Boolean(v)),
),
);
if (addrs.length > 0) {
2026-01-09 05:27:54 +00:00
defaultRuntime.log(
`${label("Listening:")} ${infoText(addrs.join(", "))}`,
);
}
}
if (status.portCli && status.portCli.port !== status.port?.port) {
defaultRuntime.log(
2026-01-09 03:22:02 +00:00
`${label("Note:")} CLI config resolves gateway port=${status.portCli.port} (${status.portCli.status}).`,
);
}
if (
service.loaded &&
service.runtime?.status === "running" &&
status.port &&
status.port.status !== "busy"
) {
defaultRuntime.error(
2026-01-09 03:22:02 +00:00
errorText(
`Gateway port ${status.port.port} is not listening (service appears running).`,
),
);
if (status.lastError) {
2026-01-09 03:22:02 +00:00
defaultRuntime.error(
`${errorText("Last gateway error:")} ${status.lastError}`,
);
}
if (process.platform === "linux") {
2026-01-11 02:27:07 +01:00
const env = (service.command?.environment ??
process.env) as NodeJS.ProcessEnv;
const unit = resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
defaultRuntime.error(
2026-01-09 03:22:02 +00:00
errorText(
`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`,
2026-01-09 03:22:02 +00:00
),
);
} else if (process.platform === "darwin") {
const logs = resolveGatewayLogPaths(
(service.command?.environment ?? process.env) as NodeJS.ProcessEnv,
);
2026-01-09 03:22:02 +00:00
defaultRuntime.error(`${errorText("Logs:")} ${logs.stdoutPath}`);
defaultRuntime.error(`${errorText("Errors:")} ${logs.stderrPath}`);
}
spacer();
}
2026-01-07 21:37:05 +01:00
if (legacyServices.length > 0) {
2026-01-09 03:22:02 +00:00
defaultRuntime.error(errorText("Legacy Clawdis services detected:"));
2026-01-07 21:37:05 +01:00
for (const svc of legacyServices) {
2026-01-09 03:22:02 +00:00
defaultRuntime.error(`- ${errorText(svc.label)} (${svc.detail})`);
2026-01-07 21:37:05 +01:00
}
2026-01-09 03:22:02 +00:00
defaultRuntime.error(errorText("Cleanup: clawdbot doctor"));
spacer();
2026-01-07 21:37:05 +01:00
}
if (extraServices.length > 0) {
2026-01-09 03:22:02 +00:00
defaultRuntime.error(
errorText("Other gateway-like services detected (best effort):"),
);
2026-01-07 21:37:05 +01:00
for (const svc of extraServices) {
2026-01-09 03:22:02 +00:00
defaultRuntime.error(
`- ${errorText(svc.label)} (${svc.scope}, ${svc.detail})`,
);
2026-01-07 21:37:05 +01:00
}
for (const hint of renderGatewayServiceCleanupHints()) {
2026-01-09 03:22:02 +00:00
defaultRuntime.error(`${errorText("Cleanup hint:")} ${hint}`);
2026-01-07 21:37:05 +01:00
}
spacer();
2026-01-07 21:37:05 +01:00
}
if (legacyServices.length > 0 || extraServices.length > 0) {
defaultRuntime.error(
2026-01-09 03:22:02 +00:00
errorText(
"Recommendation: run a single gateway per machine. One gateway supports multiple agents.",
),
2026-01-07 21:37:05 +01:00
);
defaultRuntime.error(
2026-01-09 03:22:02 +00:00
errorText(
"If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).",
),
2026-01-07 21:37:05 +01:00
);
spacer();
2026-01-07 21:37:05 +01:00
}
2026-01-09 03:22:02 +00:00
defaultRuntime.log(`${label("Troubles:")} run clawdbot status`);
defaultRuntime.log(
`${label("Troubleshooting:")} https://docs.clawd.bot/troubleshooting`,
);
2026-01-07 21:37:05 +01:00
}
export async function runDaemonStatus(opts: DaemonStatusOptions) {
try {
const status = await gatherDaemonStatus({
rpc: opts.rpc,
probe: Boolean(opts.probe),
deep: Boolean(opts.deep),
});
printDaemonStatus(status, { json: Boolean(opts.json) });
} catch (err) {
2026-01-09 03:22:02 +00:00
const rich = isRich();
defaultRuntime.error(
colorize(rich, theme.error, `Daemon status failed: ${String(err)}`),
);
2026-01-07 21:37:05 +01:00
defaultRuntime.exit(1);
}
}
export async function runDaemonInstall(opts: DaemonInstallOptions) {
if (resolveIsNixMode(process.env)) {
defaultRuntime.error("Nix mode detected; daemon install is disabled.");
defaultRuntime.exit(1);
return;
}
const cfg = loadConfig();
const portOverride = parsePort(opts.port);
if (opts.port !== undefined && portOverride === null) {
defaultRuntime.error("Invalid port");
defaultRuntime.exit(1);
return;
}
const port = portOverride ?? resolveGatewayPort(cfg);
if (!Number.isFinite(port) || port <= 0) {
defaultRuntime.error("Invalid port");
defaultRuntime.exit(1);
return;
}
const runtimeRaw = opts.runtime
? String(opts.runtime)
: DEFAULT_GATEWAY_DAEMON_RUNTIME;
if (!isGatewayDaemonRuntime(runtimeRaw)) {
defaultRuntime.error('Invalid --runtime (use "node" or "bun")');
defaultRuntime.exit(1);
return;
}
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
2026-01-07 21:37:05 +01:00
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env, profile });
2026-01-07 21:37:05 +01:00
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
return;
}
if (loaded) {
if (!opts.force) {
defaultRuntime.log(`Gateway service already ${service.loadedText}.`);
defaultRuntime.log("Reinstall with: clawdbot daemon install --force");
return;
}
2026-01-07 21:37:05 +01:00
}
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const nodePath = await resolvePreferredNodePath({
env: process.env,
runtime: runtimeRaw,
});
2026-01-07 21:37:05 +01:00
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: runtimeRaw,
nodePath,
2026-01-07 21:37:05 +01:00
});
2026-01-12 08:33:28 +00:00
if (runtimeRaw === "node") {
const systemNode = await resolveSystemNodeInfo({ env: process.env });
const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
if (warning) defaultRuntime.log(warning);
}
const environment = buildServiceEnvironment({
env: process.env,
port,
token:
2026-01-07 21:37:05 +01:00
opts.token ||
cfg.gateway?.auth?.token ||
process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel:
process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(profile)
: undefined,
});
2026-01-07 21:37:05 +01:00
try {
await service.install({
env: process.env,
stdout: process.stdout,
programArguments,
workingDirectory,
environment,
});
} catch (err) {
defaultRuntime.error(`Gateway install failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}
export async function runDaemonUninstall() {
if (resolveIsNixMode(process.env)) {
defaultRuntime.error("Nix mode detected; daemon uninstall is disabled.");
defaultRuntime.exit(1);
return;
}
const service = resolveGatewayService();
try {
await service.uninstall({ env: process.env, stdout: process.stdout });
} catch (err) {
defaultRuntime.error(`Gateway uninstall failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}
export async function runDaemonStart() {
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
2026-01-07 21:37:05 +01:00
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env, profile });
2026-01-07 21:37:05 +01:00
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
return;
}
if (!loaded) {
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
for (const hint of renderGatewayServiceStartHints()) {
defaultRuntime.log(`Start with: ${hint}`);
}
return;
}
try {
await service.restart({
env: process.env,
profile,
stdout: process.stdout,
});
2026-01-07 21:37:05 +01:00
} catch (err) {
defaultRuntime.error(`Gateway start failed: ${String(err)}`);
for (const hint of renderGatewayServiceStartHints()) {
defaultRuntime.error(`Start with: ${hint}`);
}
defaultRuntime.exit(1);
}
}
export async function runDaemonStop() {
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
2026-01-07 21:37:05 +01:00
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env, profile });
2026-01-07 21:37:05 +01:00
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
return;
}
if (!loaded) {
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
return;
}
try {
await service.stop({ env: process.env, profile, stdout: process.stdout });
2026-01-07 21:37:05 +01:00
} catch (err) {
defaultRuntime.error(`Gateway stop failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}
/**
* Restart the gateway daemon service.
* @returns `true` if restart succeeded, `false` if the service was not loaded.
* Throws/exits on check or restart failures.
*/
export async function runDaemonRestart(): Promise<boolean> {
2026-01-07 21:37:05 +01:00
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
2026-01-07 21:37:05 +01:00
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env, profile });
2026-01-07 21:37:05 +01:00
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
return false;
2026-01-07 21:37:05 +01:00
}
if (!loaded) {
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
for (const hint of renderGatewayServiceStartHints()) {
defaultRuntime.log(`Start with: ${hint}`);
}
return false;
2026-01-07 21:37:05 +01:00
}
try {
await service.restart({
env: process.env,
profile,
stdout: process.stdout,
});
return true;
2026-01-07 21:37:05 +01:00
} catch (err) {
defaultRuntime.error(`Gateway restart failed: ${String(err)}`);
defaultRuntime.exit(1);
return false;
2026-01-07 21:37:05 +01:00
}
}
export function registerDaemonCli(program: Command) {
const daemon = program
.command("daemon")
2026-01-10 20:50:17 +01:00
.description("Manage the Gateway daemon service (launchd/systemd/schtasks)")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink(
"/gateway",
"docs.clawd.bot/gateway",
)}\n`,
2026-01-07 21:37:05 +01:00
);
daemon
.command("status")
.description("Show daemon install status + probe the Gateway")
.option(
"--url <url>",
"Gateway WebSocket URL (defaults to config/remote/local)",
)
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (password auth)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--no-probe", "Skip RPC probe")
.option("--deep", "Scan system-level services", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStatus({
rpc: opts,
probe: Boolean(opts.probe),
deep: Boolean(opts.deep),
json: Boolean(opts.json),
});
});
daemon
.command("install")
.description("Install the Gateway service (launchd/systemd/schtasks)")
.option("--port <port>", "Gateway port")
.option("--runtime <runtime>", "Daemon runtime (node|bun). Default: node")
.option("--token <token>", "Gateway token (token auth)")
.option("--force", "Reinstall/overwrite if already installed", false)
2026-01-07 21:37:05 +01:00
.action(async (opts) => {
await runDaemonInstall(opts);
});
daemon
.command("uninstall")
.description("Uninstall the Gateway service (launchd/systemd/schtasks)")
.action(async () => {
await runDaemonUninstall();
});
daemon
.command("start")
.description("Start the Gateway service (launchd/systemd/schtasks)")
.action(async () => {
await runDaemonStart();
});
daemon
.command("stop")
.description("Stop the Gateway service (launchd/systemd/schtasks)")
.action(async () => {
await runDaemonStop();
});
daemon
.command("restart")
.description("Restart the Gateway service (launchd/systemd/schtasks)")
.action(async () => {
await runDaemonRestart();
});
// Build default deps (parity with other commands).
void createDefaultDeps();
}