Compare commits

..
Author SHA1 Message Date
Peter Steinberger 50faccaba8 fix: register lazy subcommands before parse (#1683) (thanks @grrowl) 2026-01-25 03:16:41 +00:00
Tom McKenzieandClaude Sonnet 4.5 4d59d1758c CLI: fix subcommand registration to work without --help/--version flags
## Problem

The clawdbot-gateway systemd service was crash-looping on Linux (Fedora 42,
aarch64) with the error:

    error: unknown command '/usr/bin/node-22'

After ~20 seconds of runtime, the gateway would exit with status 1/FAILURE
and systemd would restart it, repeating the cycle indefinitely (80+ restarts
observed).

## Root Cause Analysis

### Investigation Steps

1. Examined systemd service logs via `journalctl --user -u clawdbot-gateway.service`
2. Found the error appeared consistently after the service had been running
   for 20-30 seconds
3. Added debug logging to trace argv at parseAsync() call
4. Discovered that argv was being passed to Commander.js with the node binary
   and script paths still present: `["/usr/bin/node-22", "/path/to/entry.js", "gateway", "--port", "18789"]`
5. Traced the issue to the lazy subcommand registration logic in runCli()

### The Bug

The lazy-loading logic for subcommands was gated behind `hasHelpOrVersion(parseArgv)`:

```typescript
if (hasHelpOrVersion(parseArgv)) {
  const primary = getPrimaryCommand(parseArgv);
  if (primary) {
    const { registerSubCliByName } = await import("./program/register.subclis.js");
    await registerSubCliByName(program, primary);
  }
}
```

This meant that when running `clawdbot gateway --port 18789` (without --help
or --version), the `gateway` subcommand was never registered before
`program.parseAsync(parseArgv)` was called. Commander.js would then try to
parse the arguments without knowing about the gateway command, leading to
parse errors.

The error message "unknown command '/usr/bin/node-22'" appeared because
Commander was treating the first positional argument as a command name due to
argv not being properly stripped on non-Windows platforms in some code paths.

## The Fix

Remove the `hasHelpOrVersion()` gate and always register the primary
subcommand when one is detected:

```typescript
// Register the primary subcommand if one exists (for lazy-loading)
const primary = getPrimaryCommand(parseArgv);
if (primary) {
  const { registerSubCliByName } = await import("./program/register.subclis.js");
  await registerSubCliByName(program, primary);
}
```

This ensures that subcommands like `gateway` are properly registered before
parsing begins, regardless of what flags are present.

## Environment

- OS: Fedora 42 (Linux 6.15.9-201.fc42.aarch64)
- Arch: aarch64
- Node: /usr/bin/node-22 (symlink to node-22)
- Deployment: systemd user service
- Runtime: Gateway started via `clawdbot gateway --port 18789`

## Why This Should Be Merged

1. **Critical Bug**: The gateway service cannot run reliably on Linux without
   this fix, making it a blocking issue for production deployments via systemd.

2. **Affects All Non-Help Invocations**: Any direct subcommand invocation
   (gateway, channels, etc.) without --help/--version is broken.

3. **Simple & Safe Fix**: The change removes an unnecessary condition that was
   preventing lazy-loading from working correctly. Subcommands should always be
   registered when detected, not just for help/version requests.

4. **No Regression Risk**: The fix maintains the lazy-loading behavior (only
   loads the requested subcommand), just ensures it works in all cases instead
   of only help/version scenarios.

5. **Tested**: Verified that the gateway service now runs stably for extended
   periods (45+ seconds continuous runtime with no crashes) after applying this
   fix.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-25 13:59:31 +11:00
Peter Steinberger b1a555da13 fix: skip tailscale dns probe when off 2026-01-25 02:51:20 +00:00
Peter Steinberger c92aaca8b0 docs: answer local data storage faq 2026-01-25 02:48:28 +00:00
Peter Steinberger c3e777e3e1 fix: keep raw config edits scoped to config view (#1673) (thanks @Glucksberg) 2026-01-25 02:48:07 +00:00
Peter Steinberger 2e3b14187b fix: stabilize venice model discovery 2026-01-25 02:43:08 +00:00
Peter Steinberger f8a22521bd docs: clarify WSL2 recommendation 2026-01-25 02:30:09 +00:00
15 changed files with 115 additions and 78 deletions

No files matched your search

+2 -2
View File
@@ -21,7 +21,6 @@ Docs: https://docs.clawd.bot
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
- Web UI: hide internal `message_id` hints in chat bubbles.
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
- Web UI: keep raw config edits from toggling channel save state; enable save/apply on raw changes only. (#1673) Thanks @Glucksberg.
- Heartbeat: normalize target identifiers for consistent routing.
- TUI: reload history after gateway reconnect to restore session state. (#1663)
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
@@ -29,12 +28,13 @@ Docs: https://docs.clawd.bot
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
- Agents: use the active auth profile for auto-compaction recovery.
- Models: default missing custom provider fields so minimal configs are accepted.
- Gateway: honor trusted proxy client IPs for local pairing + HTTP checks. (#1654) Thanks @ndbroadbent.
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
- Google Chat: normalize space targets without double `spaces/` prefix.
- CLI: register lazy subcommands before parse so non-help invocations work. (#1683) Thanks @grrowl.
## 2026.1.23-1
+15
View File
@@ -66,6 +66,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [How does memory work?](#how-does-memory-work)
- [Does semantic memory search require an OpenAI API key?](#does-semantic-memory-search-require-an-openai-api-key)
- [Where things live on disk](#where-things-live-on-disk)
- [Is all data used with Clawdbot saved locally?](#is-all-data-used-with-clawdbot-saved-locally)
- [Where does Clawdbot store its data?](#where-does-clawdbot-store-its-data)
- [Where should AGENTS.md / SOUL.md / USER.md / MEMORY.md live?](#where-should-agentsmd--soulmd--usermd--memorymd-live)
- [What’s the recommended backup strategy?](#whats-the-recommended-backup-strategy)
@@ -918,6 +919,20 @@ models — see [Memory](/concepts/memory) for the setup details.
## Where things live on disk
### Is all data used with Clawdbot saved locally?
No — **Clawdbot’s state is local**, but **external services still see what you send them**.
- **Local by default:** sessions, memory files, config, and workspace live on the Gateway host
(`~/.clawdbot` + your workspace directory).
- **Remote by necessity:** messages you send to model providers (Anthropic/OpenAI/etc.) go to
their APIs, and chat platforms (WhatsApp/Telegram/Slack/etc.) store message data on their
servers.
- **You control the footprint:** using local models keeps prompts on your machine, but channel
traffic still goes through the channel’s servers.
Related: [Agent workspace](/concepts/agent-workspace), [Memory](/concepts/memory).
### Where does Clawdbot store its data?
Everything lives under `$CLAWDBOT_STATE_DIR` (default: `~/.clawdbot`):
+2 -1
View File
@@ -7,7 +7,8 @@ read_when:
# Windows (WSL2)
Clawdbot on Windows is recommended **via WSL2** (Ubuntu recommended). The
CLI + Gateway run inside Linux, which keeps the runtime consistent. Native
CLI + Gateway run inside Linux, which keeps the runtime consistent and makes
tooling far more compatible (Node/Bun/pnpm, Linux binaries, skills). Native
Windows installs are untested and more problematic.
Native Windows companion apps are planned.
+1 -1
View File
@@ -45,7 +45,7 @@ run on host, set an explicit per-agent override:
See [Web tools](/tools/web).
macOS: if you plan to build the apps, install Xcode / CLT. For the CLI + gateway only, Node is enough.
Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested and more problematic. Install WSL2 first, then run the Linux steps inside WSL. See [Windows (WSL2)](/platforms/windows).
Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested, more problematic, and has poorer tool compatibility. Install WSL2 first, then run the Linux steps inside WSL. See [Windows (WSL2)](/platforms/windows).
## 1) Install the CLI (recommended)
@@ -48,29 +48,19 @@ describe("models-config", () => {
const previous = process.env.COPILOT_GITHUB_TOKEN;
const previousGh = process.env.GH_TOKEN;
const previousGithub = process.env.GITHUB_TOKEN;
const previousVenice = process.env.VENICE_API_KEY;
const previousKimiCode = process.env.KIMICODE_API_KEY;
const previousKimiCodeAlt = process.env.KIMI_CODE_API_KEY;
const previousMinimax = process.env.MINIMAX_API_KEY;
const previousMoonshot = process.env.MOONSHOT_API_KEY;
const previousSynthetic = process.env.SYNTHETIC_API_KEY;
const previousAwsAccessKey = process.env.AWS_ACCESS_KEY_ID;
const previousAwsSecretKey = process.env.AWS_SECRET_ACCESS_KEY;
const previousAwsProfile = process.env.AWS_PROFILE;
const previousAwsBearer = process.env.AWS_BEARER_TOKEN;
const previousVenice = process.env.VENICE_API_KEY;
delete process.env.COPILOT_GITHUB_TOKEN;
delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.VENICE_API_KEY;
delete process.env.KIMICODE_API_KEY;
delete process.env.KIMI_CODE_API_KEY;
delete process.env.MINIMAX_API_KEY;
delete process.env.MOONSHOT_API_KEY;
delete process.env.SYNTHETIC_API_KEY;
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
delete process.env.AWS_PROFILE;
delete process.env.AWS_BEARER_TOKEN;
delete process.env.VENICE_API_KEY;
try {
vi.resetModules();
@@ -93,26 +83,16 @@ describe("models-config", () => {
else process.env.GH_TOKEN = previousGh;
if (previousGithub === undefined) delete process.env.GITHUB_TOKEN;
else process.env.GITHUB_TOKEN = previousGithub;
if (previousVenice === undefined) delete process.env.VENICE_API_KEY;
else process.env.VENICE_API_KEY = previousVenice;
if (previousKimiCode === undefined) delete process.env.KIMICODE_API_KEY;
else process.env.KIMICODE_API_KEY = previousKimiCode;
if (previousKimiCodeAlt === undefined) delete process.env.KIMI_CODE_API_KEY;
else process.env.KIMI_CODE_API_KEY = previousKimiCodeAlt;
if (previousMinimax === undefined) delete process.env.MINIMAX_API_KEY;
else process.env.MINIMAX_API_KEY = previousMinimax;
if (previousMoonshot === undefined) delete process.env.MOONSHOT_API_KEY;
else process.env.MOONSHOT_API_KEY = previousMoonshot;
if (previousSynthetic === undefined) delete process.env.SYNTHETIC_API_KEY;
else process.env.SYNTHETIC_API_KEY = previousSynthetic;
if (previousAwsAccessKey === undefined) delete process.env.AWS_ACCESS_KEY_ID;
else process.env.AWS_ACCESS_KEY_ID = previousAwsAccessKey;
if (previousAwsSecretKey === undefined) delete process.env.AWS_SECRET_ACCESS_KEY;
else process.env.AWS_SECRET_ACCESS_KEY = previousAwsSecretKey;
if (previousAwsProfile === undefined) delete process.env.AWS_PROFILE;
else process.env.AWS_PROFILE = previousAwsProfile;
if (previousAwsBearer === undefined) delete process.env.AWS_BEARER_TOKEN;
else process.env.AWS_BEARER_TOKEN = previousAwsBearer;
if (previousVenice === undefined) delete process.env.VENICE_API_KEY;
else process.env.VENICE_API_KEY = previousVenice;
}
});
});
+28 -29
View File
@@ -1,37 +1,36 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { rewriteUpdateFlagArgv } from "./run-main.js";
const registerSubCliByName = vi.fn(async () => true);
const parseAsync = vi.fn(async () => undefined);
const buildProgram = vi.fn(() => ({ parseAsync }));
describe("rewriteUpdateFlagArgv", () => {
it("leaves argv unchanged when --update is absent", () => {
const argv = ["node", "entry.js", "status"];
expect(rewriteUpdateFlagArgv(argv)).toBe(argv);
vi.mock("../infra/dotenv.js", () => ({ loadDotEnv: vi.fn() }));
vi.mock("../infra/env.js", () => ({ normalizeEnv: vi.fn() }));
vi.mock("../infra/path-env.js", () => ({ ensureClawdbotCliOnPath: vi.fn() }));
vi.mock("../infra/runtime-guard.js", () => ({ assertSupportedRuntime: vi.fn() }));
vi.mock("../infra/errors.js", () => ({ formatUncaughtError: vi.fn(() => "error") }));
vi.mock("../infra/unhandled-rejections.js", () => ({ installUnhandledRejectionHandler: vi.fn() }));
vi.mock("../logging.js", () => ({ enableConsoleCapture: vi.fn() }));
vi.mock("./program.js", () => ({ buildProgram }));
vi.mock("./program/register.subclis.js", () => ({ registerSubCliByName }));
vi.mock("./route.js", () => ({ tryRouteCli: vi.fn(async () => false) }));
const { runCli } = await import("./run-main.js");
describe("runCli", () => {
afterEach(() => {
registerSubCliByName.mockClear();
parseAsync.mockClear();
buildProgram.mockClear();
});
it("rewrites --update into the update command", () => {
expect(rewriteUpdateFlagArgv(["node", "entry.js", "--update"])).toEqual([
"node",
"entry.js",
"update",
]);
});
it("registers the primary subcommand before parsing", async () => {
const argv = ["/usr/bin/node-22", "/opt/clawdbot/entry.js", "gateway", "--port", "18789"];
it("preserves global flags that appear before --update", () => {
expect(rewriteUpdateFlagArgv(["node", "entry.js", "--profile", "p", "--update"])).toEqual([
"node",
"entry.js",
"--profile",
"p",
"update",
]);
});
await runCli(argv);
it("keeps update options after the rewritten command", () => {
expect(rewriteUpdateFlagArgv(["node", "entry.js", "--update", "--json"])).toEqual([
"node",
"entry.js",
"update",
"--json",
]);
expect(registerSubCliByName).toHaveBeenCalledTimes(1);
expect(registerSubCliByName).toHaveBeenCalledWith(expect.any(Object), "gateway");
expect(parseAsync).toHaveBeenCalledWith(argv);
});
});
+6 -7
View File
@@ -11,7 +11,7 @@ import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { formatUncaughtError } from "../infra/errors.js";
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { enableConsoleCapture } from "../logging.js";
import { getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
import { getPrimaryCommand } from "./argv.js";
import { tryRouteCli } from "./route.js";
export function rewriteUpdateFlagArgv(argv: string[]): string[] {
@@ -50,12 +50,11 @@ export async function runCli(argv: string[] = process.argv) {
});
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
if (hasHelpOrVersion(parseArgv)) {
const primary = getPrimaryCommand(parseArgv);
if (primary) {
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary);
}
// Register the primary subcommand if one exists (for lazy-loading)
const primary = getPrimaryCommand(parseArgv);
if (primary) {
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary);
}
await program.parseAsync(parseArgv);
}
+1 -3
View File
@@ -353,9 +353,7 @@ export function applyVeniceProviderConfig(cfg: ClawdbotConfig): ClawdbotConfig {
const veniceModels = VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition);
const mergedModels = [
...existingModels,
...veniceModels.filter(
(model) => !existingModels.some((existing) => existing.id === model.id),
),
...veniceModels.filter((model) => !existingModels.some((existing) => existing.id === model.id)),
];
const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record<
string,
+1 -4
View File
@@ -2,10 +2,7 @@ export {
SYNTHETIC_DEFAULT_MODEL_ID,
SYNTHETIC_DEFAULT_MODEL_REF,
} from "../agents/synthetic-models.js";
export {
VENICE_DEFAULT_MODEL_ID,
VENICE_DEFAULT_MODEL_REF,
} from "../agents/venice-models.js";
export { VENICE_DEFAULT_MODEL_ID, VENICE_DEFAULT_MODEL_REF } from "../agents/venice-models.js";
export {
applyAuthProfileConfig,
applyKimiCodeConfig,
+5 -1
View File
@@ -13,6 +13,7 @@ export async function startGatewayDiscovery(params: {
gatewayTls?: { enabled: boolean; fingerprintSha256?: string };
canvasPort?: number;
wideAreaDiscoveryEnabled: boolean;
tailscaleMode: "off" | "serve" | "funnel";
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
}) {
let bonjourStop: (() => Promise<void>) | null = null;
@@ -20,8 +21,11 @@ export async function startGatewayDiscovery(params: {
process.env.CLAWDBOT_DISABLE_BONJOUR !== "1" &&
process.env.NODE_ENV !== "test" &&
!process.env.VITEST;
const tailscaleEnabled = params.tailscaleMode !== "off";
const needsTailnetDns = bonjourEnabled || params.wideAreaDiscoveryEnabled;
const tailnetDns = needsTailnetDns ? await resolveTailnetDnsHint() : undefined;
const tailnetDns = needsTailnetDns
? await resolveTailnetDnsHint({ enabled: tailscaleEnabled })
: undefined;
const sshPortEnv = process.env.CLAWDBOT_SSH_PORT?.trim();
const sshPortParsed = sshPortEnv ? Number.parseInt(sshPortEnv, 10) : NaN;
const sshPort = Number.isFinite(sshPortParsed) && sshPortParsed > 0 ? sshPortParsed : undefined;
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const getTailnetHostname = vi.hoisted(() => vi.fn());
vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname }));
import { resolveTailnetDnsHint } from "./server-discovery.js";
describe("resolveTailnetDnsHint", () => {
const prevTailnetDns = { value: undefined as string | undefined };
beforeEach(() => {
prevTailnetDns.value = process.env.CLAWDBOT_TAILNET_DNS;
delete process.env.CLAWDBOT_TAILNET_DNS;
getTailnetHostname.mockReset();
});
afterEach(() => {
if (prevTailnetDns.value === undefined) {
delete process.env.CLAWDBOT_TAILNET_DNS;
} else {
process.env.CLAWDBOT_TAILNET_DNS = prevTailnetDns.value;
}
});
test("returns env hint when disabled", async () => {
process.env.CLAWDBOT_TAILNET_DNS = "studio.tailnet.ts.net.";
const value = await resolveTailnetDnsHint({ enabled: false });
expect(value).toBe("studio.tailnet.ts.net");
expect(getTailnetHostname).not.toHaveBeenCalled();
});
test("skips tailscale lookup when disabled", async () => {
const value = await resolveTailnetDnsHint({ enabled: false });
expect(value).toBeUndefined();
expect(getTailnetHostname).not.toHaveBeenCalled();
});
test("uses tailscale lookup when enabled", async () => {
getTailnetHostname.mockResolvedValue("host.tailnet.ts.net");
const value = await resolveTailnetDnsHint({ enabled: true });
expect(value).toBe("host.tailnet.ts.net");
expect(getTailnetHostname).toHaveBeenCalledTimes(1);
});
});
+2
View File
@@ -55,11 +55,13 @@ export function resolveBonjourCliPath(opts: ResolveBonjourCliPathOptions = {}):
export async function resolveTailnetDnsHint(opts?: {
env?: NodeJS.ProcessEnv;
exec?: typeof runExec;
enabled?: boolean;
}): Promise<string | undefined> {
const env = opts?.env ?? process.env;
const envRaw = env.CLAWDBOT_TAILNET_DNS?.trim();
const envValue = envRaw && envRaw.length > 0 ? envRaw.replace(/\.$/, "") : "";
if (envValue) return envValue;
if (opts?.enabled === false) return undefined;
const exec =
opts?.exec ??
+1
View File
@@ -339,6 +339,7 @@ export async function startGatewayServer(
? { enabled: true, fingerprintSha256: gatewayTls.fingerprintSha256 }
: undefined,
wideAreaDiscoveryEnabled: cfgAtStart.discovery?.wideArea?.enabled === true,
tailscaleMode,
logDiscovery,
});
bonjourStop = discovery.bonjourStop;
+1 -4
View File
@@ -134,10 +134,7 @@ export function connectGateway(host: GatewayHost) {
},
onClose: ({ code, reason }) => {
host.connected = false;
// Code 1012 = Service Restart (expected during config saves, don't show as error)
if (code !== 1012) {
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
}
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
},
onEvent: (evt) => handleGatewayEvent(host, evt),
onGap: ({ expected, received }) => {
+1 -2
View File
@@ -234,9 +234,8 @@ export function renderConfig(props: ConfigProps) {
const hasChanges = props.formMode === "form" ? diff.length > 0 : hasRawChanges;
// Save/apply buttons require actual changes to be enabled
// Note: formUnsafe warns about unsupported schema paths but shouldn't block saving
const canSaveForm =
Boolean(props.formValue) && !props.loading;
Boolean(props.formValue) && !props.loading && !formUnsafe;
const canSave =
props.connected &&
!props.saving &&