Merge pull request #969 from bjesuiter/fix/launchd-label-resolution

fix: unify daemon service label resolution with env parameter
This commit is contained in:
Peter Steinberger
2026-01-15 22:12:12 +00:00
committed by GitHub
34 changed files with 495 additions and 109 deletions

View File

@@ -2,6 +2,8 @@
## 2026.1.15 (unreleased)
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot``act`.

View File

@@ -78,7 +78,7 @@ clawdbot gateway health --url ws://127.0.0.1:18789
- your configured remote gateway (if set), and
- localhost (loopback) **even if remote is configured**.
If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use profiles for redundancy, but most installs still run a single gateway.
If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use isolated profiles/ports (e.g., a rescue bot), but most installs still run a single gateway.
```bash
clawdbot gateway status

View File

@@ -2512,7 +2512,7 @@ Requires full Gateway restart:
### Multi-instance isolation
To run multiple gateways on one host, isolate per-instance state + config and use unique ports:
To run multiple gateways on one host (for redundancy or a rescue bot), isolate per-instance state + config and use unique ports:
- `CLAWDBOT_CONFIG_PATH` (per-instance config)
- `CLAWDBOT_STATE_DIR` (sessions/creds)
- `agents.defaults.workspace` (memories)

View File

@@ -16,7 +16,7 @@ The design goal is to keep all network discovery/advertising in the **Node Gatew
## Terms
- **Gateway**: the single, long-running gateway process that owns state (sessions, pairing, node registry) and runs channels.
- **Gateway**: a single long-running gateway process that owns state (sessions, pairing, node registry) and runs channels. Most setups use one per host; isolated multi-gateway setups are possible.
- **Gateway WS (loopback)**: the existing gateway WebSocket control endpoint on `127.0.0.1:18789`.
- **Bridge (direct transport)**: a LAN/tailnet-facing endpoint owned by the gateway that allows authenticated clients/nodes to call a scoped subset of gateway methods. The bridge exists so the gateway can remain loopback-only.
- **SSH transport (fallback)**: remote control by forwarding `127.0.0.1:18789` over SSH.

View File

@@ -9,7 +9,7 @@ read_when:
Last updated: 2025-12-11
## Why
- Ensure only one gateway instance runs per host.
- Ensure only one gateway instance runs per base port on the same host; additional gateways must use isolated profiles and unique ports.
- Survive crashes/SIGKILL without leaving stale lock files.
- Fail fast with a clear error when the control port is already occupied.

View File

@@ -63,6 +63,8 @@ Install metadata is embedded in the service config:
- `CLAWDBOT_SERVICE_KIND=gateway`
- `CLAWDBOT_SERVICE_VERSION=<version>`
Rescue-Bot Pattern: keep a second Gateway isolated with its own profile, state dir, workspace, and base port spacing. Full guide: [Rescue-bot guide](/gateway/multiple-gateways#rescue-bot-guide).
### Dev profile (`--dev`)
Fast path: run a fully-isolated dev instance (config/state/workspace) without touching your primary setup.
@@ -205,7 +207,7 @@ Notes:
- `daemon status` includes the last gateway error line when the service looks running but the port is closed.
- `logs` tails the Gateway file log via RPC (no manual `tail`/`grep` needed).
- If other gateway-like services are detected, the CLI warns unless they are Clawdbot profile services.
We still recommend **one gateway per machine** unless you need redundant profiles.
We still recommend **one gateway per machine** for most setups; use isolated profiles/ports for redundancy or a rescue bot. See [Multiple gateways](/gateway/multiple-gateways).
- Cleanup: `clawdbot daemon uninstall` (current service) and `clawdbot doctor` (legacy migrations).
- `daemon install` is a no-op when already installed; use `clawdbot daemon install --force` to reinstall (profile/env/path changes).

View File

@@ -6,7 +6,7 @@ read_when:
---
# Multiple Gateways (same host)
Most setups should use one Gateway because a single Gateway can handle multiple messaging connections and agents. If you need stronger isolation or redundancy, run separate Gateways. Both are supported.
Most setups should use one Gateway because a single Gateway can handle multiple messaging connections and agents. If you need stronger isolation or redundancy (e.g., a rescue bot), run separate Gateways with isolated profiles/ports.
## Isolation checklist (required)
- `CLAWDBOT_CONFIG_PATH` — per-instance config file
@@ -37,6 +37,38 @@ clawdbot --profile main daemon install
clawdbot --profile rescue daemon install
```
## Rescue-bot guide
Run a second Gateway on the same host with its own:
- profile/config
- state dir
- workspace
- base port (plus derived ports)
This keeps the rescue bot isolated from the main bot so it can debug or apply config changes if the primary bot is down.
Port spacing: leave at least 20 ports between base ports so the derived bridge/browser/canvas/CDP ports never collide.
### How to install (rescue bot)
```bash
# Main bot (existing or fresh, without --profile param)
# Runs on port 18789 + Chrome CDC/Canvas/... Ports
clawdbot onboard
clawdbot daemon install
# Rescue bot (isolated profile + ports)
clawdbot --profile rescue onboard
# Notes:
# - workspace name will be postfixed with -rescue per default
# - Port should be at least 18789 + 20 Ports,
# better choose completely different base port, like 19789,
# - rest of the onboarding is the same as normal
# To install the daemon (if not happened automatically during onboarding)
clawdbot --profile rescue daemon install
```
## Port mapping (derived)
Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`).

View File

@@ -26,7 +26,7 @@ Flow example (Telegram → node):
- Node returns the result; Gateway replies back out to Telegram.
Notes:
- **Nodes do not run the gateway daemon.** Only one gateway should run per host.
- **Nodes do not run the gateway daemon.** Only one gateway should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)).
- macOS app “node mode” is just a node client over the Bridge.
## SSH tunnel (CLI + tools)

View File

@@ -66,7 +66,7 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long
## Network model
- **One Gateway per host**: it is the only process allowed to own the WhatsApp Web session.
- **One Gateway per host (recommended)**: it is the only process allowed to own the WhatsApp Web session. If you need a rescue bot or strict isolation, run multiple gateways with isolated profiles and ports; see [Multiple gateways](/gateway/multiple-gateways).
- **Loopback-first**: Gateway WS defaults to `ws://127.0.0.1:18789`.
- The wizard now generates a gateway token by default (even for loopback).
- For Tailnet access, run `clawdbot gateway --bind tailnet --token ...` (token is required for non-loopback binds).

View File

@@ -665,7 +665,7 @@ Nodes dont see inbound provider traffic; they only receive bridge RPC calls.
### Do nodes run a gateway daemon?
No. Only **one gateway** should run per host. Nodes are peripherals that connect
No. Only **one gateway** should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). Nodes are peripherals that connect
to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.

View File

@@ -46,10 +46,9 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
}
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ profile });
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -85,7 +84,9 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
port,
token: opts.token || cfg.gateway?.auth?.token || process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel:
process.platform === "darwin" ? resolveGatewayLaunchAgentLabel(profile) : undefined,
process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
: undefined,
});
try {

View File

@@ -21,10 +21,9 @@ export async function runDaemonUninstall() {
export async function runDaemonStart() {
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ profile });
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -38,7 +37,7 @@ export async function runDaemonStart() {
return;
}
try {
await service.restart({ profile, stdout: process.stdout });
await service.restart({ env: process.env, stdout: process.stdout });
} catch (err) {
defaultRuntime.error(`Gateway start failed: ${String(err)}`);
for (const hint of renderGatewayServiceStartHints()) {
@@ -50,10 +49,9 @@ export async function runDaemonStart() {
export async function runDaemonStop() {
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ profile });
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -64,7 +62,7 @@ export async function runDaemonStop() {
return;
}
try {
await service.stop({ profile, stdout: process.stdout });
await service.stop({ env: process.env, stdout: process.stdout });
} catch (err) {
defaultRuntime.error(`Gateway stop failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -78,10 +76,9 @@ export async function runDaemonStop() {
*/
export async function runDaemonRestart(): Promise<boolean> {
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ profile });
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -95,7 +92,7 @@ export async function runDaemonRestart(): Promise<boolean> {
return false;
}
try {
await service.restart({ profile, stdout: process.stdout });
await service.restart({ env: process.env, stdout: process.stdout });
return true;
} catch (err) {
defaultRuntime.error(`Gateway restart failed: ${String(err)}`);

View File

@@ -112,7 +112,7 @@ export async function gatherDaemonStatus(
): Promise<DaemonStatus> {
const service = resolveGatewayService();
const [loaded, command, runtime] = await Promise.all([
service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }).catch(() => false),
service.isLoaded({ env: process.env }).catch(() => false),
service.readCommand(process.env).catch(() => null),
service.readRuntime(process.env).catch(() => undefined),
]);

View File

@@ -262,12 +262,12 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
if (legacyServices.length > 0 || extraServices.length > 0) {
defaultRuntime.error(
errorText(
"Recommendation: run a single gateway per machine. One gateway supports multiple agents.",
"Recommendation: run a single gateway per machine for most setups. One gateway supports multiple agents (see docs: /gateway#multiple-gateways-same-host).",
),
);
defaultRuntime.error(
errorText(
"If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).",
"If you need multiple gateways (e.g., a rescue bot on the same host), isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).",
),
);
spacer();

View File

@@ -89,7 +89,7 @@ export async function maybeExplainGatewayServiceStop() {
const service = resolveGatewayService();
let loaded: boolean | null = null;
try {
loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
loaded = await service.isLoaded({ env: process.env });
} catch {
loaded = null;
}

View File

@@ -27,7 +27,7 @@ export async function maybeInstallDaemon(params: {
daemonRuntime?: GatewayDaemonRuntime;
}) {
const service = resolveGatewayService();
const loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
const loaded = await service.isLoaded({ env: process.env });
let shouldCheckLinger = false;
let shouldInstall = true;
let daemonRuntime = params.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME;
@@ -49,7 +49,7 @@ export async function maybeInstallDaemon(params: {
async (progress) => {
progress.setLabel("Restarting Gateway daemon…");
await service.restart({
profile: process.env.CLAWDBOT_PROFILE,
env: process.env,
stdout: process.stdout,
});
progress.setLabel("Gateway daemon restarted.");

View File

@@ -37,7 +37,7 @@ export async function maybeRepairGatewayDaemon(params: {
if (params.healthOk) return;
const service = resolveGatewayService();
const loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
const loaded = await service.isLoaded({ env: process.env });
let serviceRuntime: Awaited<ReturnType<typeof service.readRuntime>> | undefined;
if (loaded) {
serviceRuntime = await service.readRuntime(process.env).catch(() => undefined);
@@ -129,7 +129,7 @@ export async function maybeRepairGatewayDaemon(params: {
});
if (start) {
await service.restart({
profile: process.env.CLAWDBOT_PROFILE,
env: process.env,
stdout: process.stdout,
});
await sleep(1500);
@@ -151,7 +151,7 @@ export async function maybeRepairGatewayDaemon(params: {
});
if (restart) {
await service.restart({
profile: process.env.CLAWDBOT_PROFILE,
env: process.env,
stdout: process.stdout,
});
await sleep(1500);

View File

@@ -89,7 +89,7 @@ export async function maybeMigrateLegacyGatewayService(
}
const service = resolveGatewayService();
const loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
const loaded = await service.isLoaded({ env: process.env });
if (loaded) {
note(`Clawdbot ${service.label} already ${service.loadedText}.`, "Gateway");
return;
@@ -280,9 +280,9 @@ export async function maybeScanExtraGatewayServices(options: DoctorOptions) {
note(
[
"Recommendation: run a single gateway per machine.",
"Recommendation: run a single gateway per machine for most setups.",
"One gateway supports multiple agents.",
"If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).",
"If you need multiple gateways (e.g., a rescue bot on the same host), isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).",
].join("\n"),
"Gateway recommendation",
);

View File

@@ -209,7 +209,7 @@ export async function doctorCommand(
const service = resolveGatewayService();
let loaded = false;
try {
loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
loaded = await service.isLoaded({ env: process.env });
} catch {
loaded = false;
}

View File

@@ -183,7 +183,7 @@ export async function gatewayStatusCommand(
warnings.push({
code: "multiple_gateways",
message:
"Unconventional setup: multiple reachable gateways detected. Usually only one gateway should exist on a network.",
"Unconventional setup: multiple reachable gateways detected. Usually one gateway per network is recommended unless you intentionally run isolated profiles, like a rescue bot (see docs: /gateway#multiple-gateways-same-host).",
targetIds: reachable.map((p) => p.target.id),
});
}

View File

@@ -38,17 +38,16 @@ const selectStyled = <T>(params: Parameters<typeof select<T>>[0]) =>
async function stopGatewayIfRunning(runtime: RuntimeEnv) {
if (isNixMode) return;
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ profile });
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
runtime.error(`Gateway service check failed: ${String(err)}`);
return;
}
if (!loaded) return;
try {
await service.stop({ profile, stdout: process.stdout });
await service.stop({ env: process.env, stdout: process.stdout });
} catch (err) {
runtime.error(`Gateway stop failed: ${String(err)}`);
}

View File

@@ -133,7 +133,7 @@ export async function statusAllCommand(
try {
const service = resolveGatewayService();
const [loaded, runtimeInfo, command] = await Promise.all([
service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }).catch(() => false),
service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined),
service.readCommand(process.env).catch(() => null),
]);

View File

@@ -10,7 +10,7 @@ export async function getDaemonStatusSummary(): Promise<{
try {
const service = resolveGatewayService();
const [loaded, runtime, command] = await Promise.all([
service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }).catch(() => false),
service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined),
service.readCommand(process.env).catch(() => null),
]);

View File

@@ -55,10 +55,9 @@ async function stopAndUninstallService(runtime: RuntimeEnv): Promise<boolean> {
return false;
}
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ profile });
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
runtime.error(`Gateway service check failed: ${String(err)}`);
return false;
@@ -68,7 +67,7 @@ async function stopAndUninstallService(runtime: RuntimeEnv): Promise<boolean> {
return true;
}
try {
await service.stop({ profile, stdout: process.stdout });
await service.stop({ env: process.env, stdout: process.stdout });
} catch (err) {
runtime.error(`Gateway stop failed: ${String(err)}`);
}

View File

@@ -98,6 +98,11 @@ describe("resolveGatewaySystemdServiceName", () => {
const result = resolveGatewaySystemdServiceName("");
expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME);
});
it("returns default service name for whitespace-only profile", () => {
const result = resolveGatewaySystemdServiceName(" ");
expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME);
});
});
describe("resolveGatewayWindowsTaskName", () => {
@@ -141,6 +146,11 @@ describe("resolveGatewayWindowsTaskName", () => {
const result = resolveGatewayWindowsTaskName("");
expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME);
});
it("returns default task name for whitespace-only profile", () => {
const result = resolveGatewayWindowsTaskName(" ");
expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME);
});
});
describe("formatGatewayServiceDescription", () => {

View File

@@ -5,7 +5,7 @@ import { PassThrough } from "node:stream";
import { describe, expect, it } from "vitest";
import { installLaunchAgent, parseLaunchctlPrint } from "./launchd.js";
import { installLaunchAgent, parseLaunchctlPrint, resolveLaunchAgentPlistPath } from "./launchd.js";
describe("launchd runtime parsing", () => {
it("parses state, pid, and exit status", () => {
@@ -108,3 +108,79 @@ describe("launchd install", () => {
}
});
});
describe("resolveLaunchAgentPlistPath", () => {
it("uses default label when CLAWDBOT_PROFILE is default", () => {
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "default" };
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist",
);
});
it("uses default label when CLAWDBOT_PROFILE is unset", () => {
const env = { HOME: "/Users/test" };
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist",
);
});
it("uses profile-specific label when CLAWDBOT_PROFILE is set to a custom value", () => {
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "jbphoenix" };
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.jbphoenix.plist",
);
});
it("prefers CLAWDBOT_LAUNCHD_LABEL over CLAWDBOT_PROFILE", () => {
const env = {
HOME: "/Users/test",
CLAWDBOT_PROFILE: "jbphoenix",
CLAWDBOT_LAUNCHD_LABEL: "com.custom.label",
};
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.custom.label.plist",
);
});
it("trims whitespace from CLAWDBOT_LAUNCHD_LABEL", () => {
const env = {
HOME: "/Users/test",
CLAWDBOT_LAUNCHD_LABEL: " com.custom.label ",
};
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.custom.label.plist",
);
});
it("ignores empty CLAWDBOT_LAUNCHD_LABEL and falls back to profile", () => {
const env = {
HOME: "/Users/test",
CLAWDBOT_PROFILE: "myprofile",
CLAWDBOT_LAUNCHD_LABEL: " ",
};
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.myprofile.plist",
);
});
it("handles case-insensitive 'Default' profile", () => {
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "Default" };
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist",
);
});
it("handles case-insensitive 'DEFAULT' profile", () => {
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "DEFAULT" };
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist",
);
});
it("trims whitespace from CLAWDBOT_PROFILE", () => {
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: " myprofile " };
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.myprofile.plist",
);
});
});

View File

@@ -24,13 +24,10 @@ const formatLine = (label: string, value: string) => {
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
};
function resolveLaunchAgentLabel(params?: {
env?: Record<string, string | undefined>;
profile?: string;
}): string {
const envLabel = params?.env?.CLAWDBOT_LAUNCHD_LABEL?.trim();
function resolveLaunchAgentLabel(args?: { env?: Record<string, string | undefined> }): string {
const envLabel = args?.env?.CLAWDBOT_LAUNCHD_LABEL?.trim();
if (envLabel) return envLabel;
return resolveGatewayLaunchAgentLabel(params?.profile);
return resolveGatewayLaunchAgentLabel(args?.env?.CLAWDBOT_PROFILE);
}
function resolveHomeDir(env: Record<string, string | undefined>): string {
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
@@ -181,12 +178,11 @@ export function parseLaunchctlPrint(output: string): LaunchctlPrintInfo {
return info;
}
export async function isLaunchAgentLoaded(params?: {
export async function isLaunchAgentLoaded(args: {
env?: Record<string, string | undefined>;
profile?: string;
}): Promise<boolean> {
const domain = resolveGuiDomain();
const label = resolveLaunchAgentLabel(params);
const label = resolveLaunchAgentLabel({ env: args.env });
const res = await execLaunchctl(["print", `${domain}/${label}`]);
return res.code === 0;
}
@@ -343,14 +339,12 @@ function isLaunchctlNotLoaded(res: { stdout: string; stderr: string; code: numbe
export async function stopLaunchAgent({
stdout,
env,
profile,
}: {
stdout: NodeJS.WritableStream;
env?: Record<string, string | undefined>;
profile?: string;
}): Promise<void> {
const domain = resolveGuiDomain();
const label = resolveLaunchAgentLabel({ env, profile });
const label = resolveLaunchAgentLabel({ env });
const res = await execLaunchctl(["bootout", `${domain}/${label}`]);
if (res.code !== 0 && !isLaunchctlNotLoaded(res)) {
throw new Error(`launchctl bootout failed: ${res.stderr || res.stdout}`.trim());
@@ -425,14 +419,12 @@ export async function installLaunchAgent({
export async function restartLaunchAgent({
stdout,
env,
profile,
}: {
stdout: NodeJS.WritableStream;
env?: Record<string, string | undefined>;
profile?: string;
}): Promise<void> {
const domain = resolveGuiDomain();
const label = resolveLaunchAgentLabel({ env, profile });
const label = resolveLaunchAgentLabel({ env });
const res = await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
if (res.code !== 0) {
throw new Error(`launchctl kickstart failed: ${res.stderr || res.stdout}`.trim());

View File

@@ -1,6 +1,10 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parseSchtasksQuery } from "./schtasks.js";
import { parseSchtasksQuery, readScheduledTaskCommand, resolveTaskScriptPath } from "./schtasks.js";
describe("schtasks runtime parsing", () => {
it("parses status and last run info", () => {
@@ -16,4 +20,222 @@ describe("schtasks runtime parsing", () => {
lastRunResult: "0x0",
});
});
it("parses running status", () => {
const output = [
"TaskName: \\Clawdbot Gateway",
"Status: Running",
"Last Run Time: 1/8/2026 1:23:45 AM",
"Last Run Result: 0x0",
].join("\r\n");
expect(parseSchtasksQuery(output)).toEqual({
status: "Running",
lastRunTime: "1/8/2026 1:23:45 AM",
lastRunResult: "0x0",
});
});
});
describe("resolveTaskScriptPath", () => {
it("uses default path when CLAWDBOT_PROFILE is default", () => {
const env = { USERPROFILE: "C:\\Users\\test", CLAWDBOT_PROFILE: "default" };
expect(resolveTaskScriptPath(env)).toBe(
path.join("C:\\Users\\test", ".clawdbot", "gateway.cmd"),
);
});
it("uses default path when CLAWDBOT_PROFILE is unset", () => {
const env = { USERPROFILE: "C:\\Users\\test" };
expect(resolveTaskScriptPath(env)).toBe(
path.join("C:\\Users\\test", ".clawdbot", "gateway.cmd"),
);
});
it("uses profile-specific path when CLAWDBOT_PROFILE is set to a custom value", () => {
const env = { USERPROFILE: "C:\\Users\\test", CLAWDBOT_PROFILE: "jbphoenix" };
expect(resolveTaskScriptPath(env)).toBe(
path.join("C:\\Users\\test", ".clawdbot-jbphoenix", "gateway.cmd"),
);
});
it("handles case-insensitive 'Default' profile", () => {
const env = { USERPROFILE: "C:\\Users\\test", CLAWDBOT_PROFILE: "Default" };
expect(resolveTaskScriptPath(env)).toBe(
path.join("C:\\Users\\test", ".clawdbot", "gateway.cmd"),
);
});
it("handles case-insensitive 'DEFAULT' profile", () => {
const env = { USERPROFILE: "C:\\Users\\test", CLAWDBOT_PROFILE: "DEFAULT" };
expect(resolveTaskScriptPath(env)).toBe(
path.join("C:\\Users\\test", ".clawdbot", "gateway.cmd"),
);
});
it("trims whitespace from CLAWDBOT_PROFILE", () => {
const env = { USERPROFILE: "C:\\Users\\test", CLAWDBOT_PROFILE: " myprofile " };
expect(resolveTaskScriptPath(env)).toBe(
path.join("C:\\Users\\test", ".clawdbot-myprofile", "gateway.cmd"),
);
});
it("falls back to HOME when USERPROFILE is not set", () => {
const env = { HOME: "/home/test", CLAWDBOT_PROFILE: "default" };
expect(resolveTaskScriptPath(env)).toBe(path.join("/home/test", ".clawdbot", "gateway.cmd"));
});
});
describe("readScheduledTaskCommand", () => {
it("parses basic command script", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-"));
try {
const scriptPath = path.join(tmpDir, ".clawdbot", "gateway.cmd");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.writeFile(
scriptPath,
["@echo off", "node gateway.js --port 18789"].join("\r\n"),
"utf8",
);
const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" };
const result = await readScheduledTaskCommand(env);
expect(result).toEqual({
programArguments: ["node", "gateway.js", "--port", "18789"],
});
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("parses script with working directory", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-"));
try {
const scriptPath = path.join(tmpDir, ".clawdbot", "gateway.cmd");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.writeFile(
scriptPath,
["@echo off", "cd /d C:\\Projects\\clawdbot", "node gateway.js"].join("\r\n"),
"utf8",
);
const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" };
const result = await readScheduledTaskCommand(env);
expect(result).toEqual({
programArguments: ["node", "gateway.js"],
workingDirectory: "C:\\Projects\\clawdbot",
});
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("parses script with environment variables", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-"));
try {
const scriptPath = path.join(tmpDir, ".clawdbot", "gateway.cmd");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.writeFile(
scriptPath,
["@echo off", "set NODE_ENV=production", "set PORT=18789", "node gateway.js"].join("\r\n"),
"utf8",
);
const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" };
const result = await readScheduledTaskCommand(env);
expect(result).toEqual({
programArguments: ["node", "gateway.js"],
environment: {
NODE_ENV: "production",
PORT: "18789",
},
});
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("parses script with quoted arguments containing spaces", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-"));
try {
const scriptPath = path.join(tmpDir, ".clawdbot", "gateway.cmd");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
// Use forward slashes which work in Windows cmd and avoid escape parsing issues
await fs.writeFile(
scriptPath,
["@echo off", '"C:/Program Files/Node/node.exe" gateway.js'].join("\r\n"),
"utf8",
);
const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" };
const result = await readScheduledTaskCommand(env);
expect(result).toEqual({
programArguments: ["C:/Program Files/Node/node.exe", "gateway.js"],
});
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("returns null when script does not exist", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-"));
try {
const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" };
const result = await readScheduledTaskCommand(env);
expect(result).toBeNull();
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("returns null when script has no command", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-"));
try {
const scriptPath = path.join(tmpDir, ".clawdbot", "gateway.cmd");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.writeFile(
scriptPath,
["@echo off", "rem This is just a comment"].join("\r\n"),
"utf8",
);
const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" };
const result = await readScheduledTaskCommand(env);
expect(result).toBeNull();
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("parses full script with all components", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-"));
try {
const scriptPath = path.join(tmpDir, ".clawdbot", "gateway.cmd");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.writeFile(
scriptPath,
[
"@echo off",
"rem Clawdbot Gateway",
"cd /d C:\\Projects\\clawdbot",
"set NODE_ENV=production",
"set CLAWDBOT_PORT=18789",
"node gateway.js --verbose",
].join("\r\n"),
"utf8",
);
const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" };
const result = await readScheduledTaskCommand(env);
expect(result).toEqual({
programArguments: ["node", "gateway.js", "--verbose"],
workingDirectory: "C:\\Projects\\clawdbot",
environment: {
NODE_ENV: "production",
CLAWDBOT_PORT: "18789",
},
});
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});

View File

@@ -21,7 +21,7 @@ function resolveHomeDir(env: Record<string, string | undefined>): string {
return home;
}
function resolveTaskScriptPath(env: Record<string, string | undefined>): string {
export function resolveTaskScriptPath(env: Record<string, string | undefined>): string {
const home = resolveHomeDir(env);
const profile = env.CLAWDBOT_PROFILE?.trim();
const suffix = profile && profile.toLowerCase() !== "default" ? `-${profile}` : "";
@@ -274,13 +274,13 @@ function isTaskNotRunning(res: { stdout: string; stderr: string; code: number })
export async function stopScheduledTask({
stdout,
profile,
env,
}: {
stdout: NodeJS.WritableStream;
profile?: string;
env?: Record<string, string | undefined>;
}): Promise<void> {
await assertSchtasksAvailable();
const taskName = resolveGatewayWindowsTaskName(profile);
const taskName = resolveGatewayWindowsTaskName(env?.CLAWDBOT_PROFILE);
const res = await execSchtasks(["/End", "/TN", taskName]);
if (res.code !== 0 && !isTaskNotRunning(res)) {
throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim());
@@ -290,13 +290,13 @@ export async function stopScheduledTask({
export async function restartScheduledTask({
stdout,
profile,
env,
}: {
stdout: NodeJS.WritableStream;
profile?: string;
env?: Record<string, string | undefined>;
}): Promise<void> {
await assertSchtasksAvailable();
const taskName = resolveGatewayWindowsTaskName(profile);
const taskName = resolveGatewayWindowsTaskName(env?.CLAWDBOT_PROFILE);
await execSchtasks(["/End", "/TN", taskName]);
const res = await execSchtasks(["/Run", "/TN", taskName]);
if (res.code !== 0) {
@@ -305,9 +305,11 @@ export async function restartScheduledTask({
stdout.write(`${formatLine("Restarted Scheduled Task", taskName)}\n`);
}
export async function isScheduledTaskInstalled(profile?: string): Promise<boolean> {
export async function isScheduledTaskInstalled(args: {
env?: Record<string, string | undefined>;
}): Promise<boolean> {
await assertSchtasksAvailable();
const taskName = resolveGatewayWindowsTaskName(profile);
const taskName = resolveGatewayWindowsTaskName(args.env?.CLAWDBOT_PROFILE);
const res = await execSchtasks(["/Query", "/TN", taskName]);
return res.code === 0;
}

View File

@@ -46,18 +46,13 @@ export type GatewayService = {
}) => Promise<void>;
stop: (args: {
env?: Record<string, string | undefined>;
profile?: string;
stdout: NodeJS.WritableStream;
}) => Promise<void>;
restart: (args: {
env?: Record<string, string | undefined>;
profile?: string;
stdout: NodeJS.WritableStream;
}) => Promise<void>;
isLoaded: (args: {
env?: Record<string, string | undefined>;
profile?: string;
}) => Promise<boolean>;
isLoaded: (args: { env?: Record<string, string | undefined> }) => Promise<boolean>;
readCommand: (env: Record<string, string | undefined>) => Promise<{
programArguments: string[];
workingDirectory?: string;
@@ -82,18 +77,16 @@ export function resolveGatewayService(): GatewayService {
stop: async (args) => {
await stopLaunchAgent({
stdout: args.stdout,
profile: args.profile,
env: args.env,
});
},
restart: async (args) => {
await restartLaunchAgent({
stdout: args.stdout,
profile: args.profile,
env: args.env,
});
},
isLoaded: async (args) => isLaunchAgentLoaded({ profile: args.profile, env: args.env }),
isLoaded: async (args) => isLaunchAgentLoaded(args),
readCommand: readLaunchAgentProgramArguments,
readRuntime: readLaunchAgentRuntime,
};
@@ -113,18 +106,16 @@ export function resolveGatewayService(): GatewayService {
stop: async (args) => {
await stopSystemdService({
stdout: args.stdout,
profile: args.profile,
env: args.env,
});
},
restart: async (args) => {
await restartSystemdService({
stdout: args.stdout,
profile: args.profile,
env: args.env,
});
},
isLoaded: async (args) => isSystemdServiceEnabled({ profile: args.profile, env: args.env }),
isLoaded: async (args) => isSystemdServiceEnabled(args),
readCommand: readSystemdServiceExecStart,
readRuntime: async (env) => await readSystemdServiceRuntime(env),
};
@@ -144,16 +135,16 @@ export function resolveGatewayService(): GatewayService {
stop: async (args) => {
await stopScheduledTask({
stdout: args.stdout,
profile: args.profile,
env: args.env,
});
},
restart: async (args) => {
await restartScheduledTask({
stdout: args.stdout,
profile: args.profile,
env: args.env,
});
},
isLoaded: async (args) => isScheduledTaskInstalled(args.profile),
isLoaded: async (args) => isScheduledTaskInstalled(args),
readCommand: readScheduledTaskCommand,
readRuntime: async (env) => await readScheduledTaskRuntime(env),
};

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { parseSystemdShow } from "./systemd.js";
import { parseSystemdShow, resolveSystemdUserUnitPath } from "./systemd.js";
describe("systemd runtime parsing", () => {
it("parses active state details", () => {
@@ -19,3 +19,78 @@ describe("systemd runtime parsing", () => {
});
});
});
describe("resolveSystemdUserUnitPath", () => {
it("uses default service name when CLAWDBOT_PROFILE is default", () => {
const env = { HOME: "/home/test", CLAWDBOT_PROFILE: "default" };
expect(resolveSystemdUserUnitPath(env)).toBe(
"/home/test/.config/systemd/user/clawdbot-gateway.service",
);
});
it("uses default service name when CLAWDBOT_PROFILE is unset", () => {
const env = { HOME: "/home/test" };
expect(resolveSystemdUserUnitPath(env)).toBe(
"/home/test/.config/systemd/user/clawdbot-gateway.service",
);
});
it("uses profile-specific service name when CLAWDBOT_PROFILE is set to a custom value", () => {
const env = { HOME: "/home/test", CLAWDBOT_PROFILE: "jbphoenix" };
expect(resolveSystemdUserUnitPath(env)).toBe(
"/home/test/.config/systemd/user/clawdbot-gateway-jbphoenix.service",
);
});
it("prefers CLAWDBOT_SYSTEMD_UNIT over CLAWDBOT_PROFILE", () => {
const env = {
HOME: "/home/test",
CLAWDBOT_PROFILE: "jbphoenix",
CLAWDBOT_SYSTEMD_UNIT: "custom-unit",
};
expect(resolveSystemdUserUnitPath(env)).toBe(
"/home/test/.config/systemd/user/custom-unit.service",
);
});
it("handles CLAWDBOT_SYSTEMD_UNIT with .service suffix", () => {
const env = {
HOME: "/home/test",
CLAWDBOT_SYSTEMD_UNIT: "custom-unit.service",
};
expect(resolveSystemdUserUnitPath(env)).toBe(
"/home/test/.config/systemd/user/custom-unit.service",
);
});
it("trims whitespace from CLAWDBOT_SYSTEMD_UNIT", () => {
const env = {
HOME: "/home/test",
CLAWDBOT_SYSTEMD_UNIT: " custom-unit ",
};
expect(resolveSystemdUserUnitPath(env)).toBe(
"/home/test/.config/systemd/user/custom-unit.service",
);
});
it("handles case-insensitive 'Default' profile", () => {
const env = { HOME: "/home/test", CLAWDBOT_PROFILE: "Default" };
expect(resolveSystemdUserUnitPath(env)).toBe(
"/home/test/.config/systemd/user/clawdbot-gateway.service",
);
});
it("handles case-insensitive 'DEFAULT' profile", () => {
const env = { HOME: "/home/test", CLAWDBOT_PROFILE: "DEFAULT" };
expect(resolveSystemdUserUnitPath(env)).toBe(
"/home/test/.config/systemd/user/clawdbot-gateway.service",
);
});
it("trims whitespace from CLAWDBOT_PROFILE", () => {
const env = { HOME: "/home/test", CLAWDBOT_PROFILE: " myprofile " };
expect(resolveSystemdUserUnitPath(env)).toBe(
"/home/test/.config/systemd/user/clawdbot-gateway-myprofile.service",
);
});
});

View File

@@ -50,14 +50,6 @@ function resolveSystemdServiceName(env: Record<string, string | undefined>): str
return resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
}
function resolveSystemdServiceNameFromParams(params?: {
env?: Record<string, string | undefined>;
profile?: string;
}): string {
if (params?.env) return resolveSystemdServiceName(params.env);
return resolveGatewaySystemdServiceName(params?.profile);
}
function resolveSystemdUnitPath(env: Record<string, string | undefined>): string {
return resolveSystemdUnitPathForName(env, resolveSystemdServiceName(env));
}
@@ -268,14 +260,12 @@ export async function uninstallSystemdService({
export async function stopSystemdService({
stdout,
env,
profile,
}: {
stdout: NodeJS.WritableStream;
env?: Record<string, string | undefined>;
profile?: string;
}): Promise<void> {
await assertSystemdAvailable();
const serviceName = resolveSystemdServiceNameFromParams({ env, profile });
const serviceName = resolveSystemdServiceName(env ?? {});
const unitName = `${serviceName}.service`;
const res = await execSystemctl(["--user", "stop", unitName]);
if (res.code !== 0) {
@@ -287,14 +277,12 @@ export async function stopSystemdService({
export async function restartSystemdService({
stdout,
env,
profile,
}: {
stdout: NodeJS.WritableStream;
env?: Record<string, string | undefined>;
profile?: string;
}): Promise<void> {
await assertSystemdAvailable();
const serviceName = resolveSystemdServiceNameFromParams({ env, profile });
const serviceName = resolveSystemdServiceName(env ?? {});
const unitName = `${serviceName}.service`;
const res = await execSystemctl(["--user", "restart", unitName]);
if (res.code !== 0) {
@@ -303,12 +291,11 @@ export async function restartSystemdService({
stdout.write(`${formatLine("Restarted systemd service", unitName)}\n`);
}
export async function isSystemdServiceEnabled(params?: {
export async function isSystemdServiceEnabled(args: {
env?: Record<string, string | undefined>;
profile?: string;
}): Promise<boolean> {
await assertSystemdAvailable();
const serviceName = resolveSystemdServiceNameFromParams(params);
const serviceName = resolveSystemdServiceName(args.env ?? {});
const unitName = `${serviceName}.service`;
const res = await execSystemctl(["--user", "is-enabled", unitName]);
return res.code === 0;

View File

@@ -32,7 +32,9 @@ export function buildPortHints(listeners: PortListener[], port: number): string[
hints.push("Another process is listening on this port.");
}
if (listeners.length > 1) {
hints.push("Multiple listeners detected; ensure only one gateway/tunnel.");
hints.push(
"Multiple listeners detected; ensure only one gateway/tunnel per port unless intentionally running isolated profiles.",
);
}
return hints;
}

View File

@@ -126,7 +126,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
);
}
const service = resolveGatewayService();
const loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
const loaded = await service.isLoaded({ env: process.env });
if (loaded) {
const action = (await prompter.select({
message: "Gateway service already installed",
@@ -143,7 +143,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
async (progress) => {
progress.update("Restarting Gateway daemon…");
await service.restart({
profile: process.env.CLAWDBOT_PROFILE,
env: process.env,
stdout: process.stdout,
});
},
@@ -160,10 +160,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
}
}
if (
!loaded ||
(loaded && (await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE })) === false)
) {
if (!loaded || (loaded && (await service.isLoaded({ env: process.env })) === false)) {
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts");
await withWizardProgress(