feat: improve gateway services and auto-reply commands

This commit is contained in:
Peter Steinberger
2026-01-11 02:17:10 +01:00
parent df55d45b6f
commit e0bf86f06c
52 changed files with 888 additions and 213 deletions

View File

@@ -77,7 +77,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 and warns this is an unconventional setup (usually you want only one gateway).
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.
```bash
clawdbot gateway status

View File

@@ -156,7 +156,7 @@ Chat messages support `/...` commands (text and native). See [/tools/slash-comma
Highlights:
- `/status` for quick diagnostics.
- `/config` for persisted config changes.
- `/debug` for runtime-only config overrides (memory, not disk).
- `/debug` for runtime-only config overrides (memory, not disk; requires `commands.debug: true`).
## Setup + onboarding
@@ -448,7 +448,7 @@ Subcommands:
Notes:
- `daemon status` probes the Gateway RPC by default using the daemons resolved port/config (override with `--url/--token/--password`).
- `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting.
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans).
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra".
- `daemon status` prints which config path the CLI uses vs which config the daemon likely uses (service env), plus the resolved probe target URL.
- `daemon install` defaults to Node runtime; use `--runtime bun` only when WhatsApp is disabled.
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`.

View File

@@ -14,6 +14,7 @@ provider mixes reasoning into normal text.
## Runtime debug overrides
Use `/debug` in chat to set **runtime-only** config overrides (memory, not disk).
`/debug` is disabled by default; enable with `commands.debug: true`.
This is handy when you need to toggle obscure settings without editing `clawdbot.json`.
Examples:

View File

@@ -614,6 +614,8 @@ Controls how chat commands are enabled across connectors.
commands: {
native: false, // register native commands when supported
text: true, // parse slash commands in chat messages
config: false, // allow /config (writes to disk)
debug: false, // allow /debug (runtime-only overrides)
restart: false, // allow /restart + gateway restart tool
useAccessGroups: true // enforce access-group allowlists/policies for commands
}
@@ -625,6 +627,8 @@ Notes:
- `commands.text: false` disables parsing chat messages for commands.
- `commands.native: true` registers native commands on supported connectors (Discord/Slack/Telegram). Platforms without native commands still rely on text commands.
- `commands.native: false` skips native registration; Discord/Telegram clear previously registered commands on startup. Slack commands are managed in the Slack app.
- `commands.config: true` enables `/config` (reads/writes `clawdbot.json`).
- `commands.debug: true` enables `/debug` (runtime-only overrides).
- `commands.restart: true` enables `/restart` and the gateway tool restart action.
- `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies.

View File

@@ -172,8 +172,9 @@ switch to legacy names if the current image is missing.
### 7) Gateway service migrations and cleanup hints
Doctor detects legacy Clawdis gateway services (launchd/systemd/schtasks) and
offers to remove them and install the Clawdbot service using the current gateway
port. It can also scan for extra gateway-like services and print cleanup hints
to ensure only one gateway runs per machine.
port. It can also scan for extra gateway-like services and print cleanup hints.
Profile-named Clawdbot gateway services are considered first-class and are not
flagged as "extra."
### 8) Security warnings
Doctor emits warnings when a provider is open to DMs without an allowlist, or

View File

@@ -51,6 +51,16 @@ pnpm gateway:watch
Supported if you isolate state + config and use unique ports.
Service names are profile-aware:
- macOS: `com.clawdbot.<profile>`
- Linux: `clawdbot-gateway-<profile>.service`
- Windows: `Clawdbot Gateway (<profile>)`
Install metadata is embedded in the service config:
- `CLAWDBOT_SERVICE_MARKER=clawdbot`
- `CLAWDBOT_SERVICE_KIND=gateway`
- `CLAWDBOT_SERVICE_VERSION=<version>`
### Dev profile (`--dev`)
Fast path: run a fully-isolated dev instance (config/state/workspace) without touching your primary setup.
@@ -160,7 +170,8 @@ See also: [Presence](/concepts/presence) for how presence is produced/deduped an
- StandardOut/Err: file paths or `syslog`
- On failure, launchd restarts; fatal misconfig should keep exiting so the operator notices.
- LaunchAgents are per-user and require a logged-in session; for headless setups use a custom LaunchDaemon (not shipped).
- `clawdbot daemon install` writes `~/Library/LaunchAgents/com.clawdbot.gateway.plist`.
- `clawdbot daemon install` writes `~/Library/LaunchAgents/com.clawdbot.gateway.plist`
(or `com.clawdbot.<profile>.plist`).
- `clawdbot doctor` audits the LaunchAgent config and can update it to current defaults.
## Daemon management (CLI)
@@ -184,15 +195,18 @@ Notes:
- `daemon status` prints config path + probe target to avoid “localhost vs LAN bind” confusion and profile mismatches.
- `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. We recommend **one gateway per machine**; one gateway can host multiple agents.
- 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.
- 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).
Bundled mac app:
- Clawdbot.app can bundle a Node-based gateway relay and install a per-user LaunchAgent labeled `com.clawdbot.gateway`.
- Clawdbot.app can bundle a Node-based gateway relay and install a per-user LaunchAgent labeled
`com.clawdbot.gateway` (or `com.clawdbot.<profile>`).
- To stop it cleanly, use `clawdbot daemon stop` (or `launchctl bootout gui/$UID/com.clawdbot.gateway`).
- To restart, use `clawdbot daemon restart` (or `launchctl kickstart -k gui/$UID/com.clawdbot.gateway`).
- `launchctl` only works if the LaunchAgent is installed; otherwise use `clawdbot daemon install` first.
- Replace the label with `com.clawdbot.<profile>` when running a named profile.
## Supervision (systemd user unit)
Clawdbot installs a **systemd user service** by default on Linux/WSL2. We
@@ -203,10 +217,10 @@ required, shared supervision).
`clawdbot daemon install` writes the user unit. `clawdbot doctor` audits the
unit and can update it to match the current recommended defaults.
Create `~/.config/systemd/user/clawdbot-gateway.service`:
Create `~/.config/systemd/user/clawdbot-gateway[-<profile>].service`:
```
[Unit]
Description=Clawdbot Gateway
Description=Clawdbot Gateway (profile: <profile>, v<version>)
After=network-online.target
Wants=network-online.target
@@ -227,16 +241,16 @@ sudo loginctl enable-linger youruser
Onboarding runs this on Linux/WSL2 (may prompt for sudo; writes `/var/lib/systemd/linger`).
Then enable the service:
```
systemctl --user enable --now clawdbot-gateway.service
systemctl --user enable --now clawdbot-gateway[-<profile>].service
```
**Alternative (system service)** - for always-on or multi-user servers, you can
install a systemd **system** unit instead of a user unit (no lingering needed).
Create `/etc/systemd/system/clawdbot-gateway.service` (copy the unit above,
Create `/etc/systemd/system/clawdbot-gateway[-<profile>].service` (copy the unit above,
switch `WantedBy=multi-user.target`, set `User=` + `WorkingDirectory=`), then:
```
sudo systemctl daemon-reload
sudo systemctl enable --now clawdbot-gateway.service
sudo systemctl enable --now clawdbot-gateway[-<profile>].service
```
## Windows (WSL2)
@@ -249,7 +263,7 @@ Windows installs should use **WSL2** and follow the Linux systemd section above.
- Debug: subscribe to `tick` and `presence` events; ensure `status` shows linked/auth age; presence entries show Gateway host and connected clients.
## Safety guarantees
- Only one Gateway per host; all sends/agent calls must go through it.
- Assume one Gateway per host by default; if you run multiple profiles, isolate ports/state and target the right instance.
- No fallback to direct Baileys connections; if the Gateway is down, sends fail fast.
- Non-connect first frames or malformed JSON are rejected and the socket is closed.
- Graceful shutdown: emit `shutdown` event before closing; clients must handle close + reconnect.

View File

@@ -48,8 +48,8 @@ Doctor/daemon will show runtime state (PID/last exit) and log hints.
- Preferred: `clawdbot logs --follow`
- File logs (always): `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` (or your configured `logging.file`)
- macOS LaunchAgent (if installed): `$CLAWDBOT_STATE_DIR/logs/gateway.log` and `gateway.err.log`
- Linux systemd (if installed): `journalctl --user -u clawdbot-gateway.service -n 200 --no-pager`
- Windows: `schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST`
- Linux systemd (if installed): `journalctl --user -u clawdbot-gateway[-<profile>].service -n 200 --no-pager`
- Windows: `schtasks /Query /TN "Clawdbot Gateway (<profile>)" /V /FO LIST`
**Enable more logging:**
- Bump file log detail (persisted JSONL):
@@ -324,7 +324,7 @@ If the gateway is supervised by launchd, killing the PID will just respawn it. S
```bash
clawdbot daemon status
clawdbot daemon stop
# Or: launchctl bootout gui/$UID/com.clawdbot.gateway
# Or: launchctl bootout gui/$UID/com.clawdbot.gateway (replace with com.clawdbot.<profile> if needed)
```
**Fix 2: Port is busy (find the listener)**
@@ -360,7 +360,7 @@ clawdbot providers login --verbose
| Log | Location |
|-----|----------|
| Gateway file logs (structured) | `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` (or `logging.file`) |
| Gateway service logs (supervisor) | macOS: `$CLAWDBOT_STATE_DIR/logs/gateway.log` + `gateway.err.log` (default: `~/.clawdbot/logs/...`; profiles use `~/.clawdbot-<profile>/logs/...`)<br />Linux: `journalctl --user -u clawdbot-gateway.service -n 200 --no-pager`<br />Windows: `schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST` |
| Gateway service logs (supervisor) | macOS: `$CLAWDBOT_STATE_DIR/logs/gateway.log` + `gateway.err.log` (default: `~/.clawdbot/logs/...`; profiles use `~/.clawdbot-<profile>/logs/...`)<br />Linux: `journalctl --user -u clawdbot-gateway[-<profile>].service -n 200 --no-pager`<br />Windows: `schtasks /Query /TN "Clawdbot Gateway (<profile>)" /V /FO LIST` |
| Session files | `$CLAWDBOT_STATE_DIR/agents/<agentId>/sessions/` |
| Media cache | `$CLAWDBOT_STATE_DIR/media/` |
| Credentials | `$CLAWDBOT_STATE_DIR/credentials/` |

View File

@@ -124,9 +124,9 @@ clawdbot logs --follow
```
If youre supervised:
- macOS launchd (app-bundled LaunchAgent): `launchctl kickstart -k gui/$UID/com.clawdbot.gateway`
- Linux systemd user service: `systemctl --user restart clawdbot-gateway.service`
- Windows (WSL2): `systemctl --user restart clawdbot-gateway.service`
- macOS launchd (app-bundled LaunchAgent): `launchctl kickstart -k gui/$UID/com.clawdbot.gateway` (use `com.clawdbot.<profile>` if set)
- Linux systemd user service: `systemctl --user restart clawdbot-gateway[-<profile>].service`
- Windows (WSL2): `systemctl --user restart clawdbot-gateway[-<profile>].service`
- `launchctl`/`systemctl` only work if the service is installed; otherwise run `clawdbot daemon install`.
Runbook + exact service labels: [Gateway runbook](/gateway)

View File

@@ -164,7 +164,7 @@ Control UI details: [Control UI](/web/control-ui)
On Linux, Clawdbot uses a systemd **user** service. After `--install-daemon`, verify:
```bash
systemctl --user status clawdbot-gateway.service
systemctl --user status clawdbot-gateway[-<profile>].service
```
If the service dies after logout, enable lingering:

View File

@@ -42,5 +42,5 @@ Use one of these (all supported):
- Repair/migrate: `clawdbot doctor` (offers to install or fix the service)
The service target depends on OS:
- macOS: LaunchAgent (`com.clawdbot.gateway`)
- Linux/WSL2: systemd user service
- macOS: LaunchAgent (`com.clawdbot.gateway` or `com.clawdbot.<profile>`)
- Linux/WSL2: systemd user service (`clawdbot-gateway[-<profile>].service`)

View File

@@ -64,11 +64,11 @@ live in the [Gateway runbook](/gateway).
Minimal setup:
Create `~/.config/systemd/user/clawdbot-gateway.service`:
Create `~/.config/systemd/user/clawdbot-gateway[-<profile>].service`:
```
[Unit]
Description=Clawdbot Gateway
Description=Clawdbot Gateway (profile: <profile>, v<version>)
After=network-online.target
Wants=network-online.target
@@ -84,5 +84,5 @@ WantedBy=default.target
Enable it:
```
systemctl --user enable --now clawdbot-gateway.service
systemctl --user enable --now clawdbot-gateway[-<profile>].service
```

View File

@@ -63,10 +63,10 @@ Version injection:
## Launchd (Gateway as LaunchAgent)
Label:
- `com.clawdbot.gateway`
- `com.clawdbot.gateway` (or `com.clawdbot.<profile>`)
Plist location (per-user):
- `~/Library/LaunchAgents/com.clawdbot.gateway.plist`
- `~/Library/LaunchAgents/com.clawdbot.gateway.plist` (or `.../com.clawdbot.<profile>.plist`)
Manager:
- The macOS app owns LaunchAgent install/update for the bundled gateway.

View File

@@ -14,7 +14,8 @@ manually in a terminal.
## Default behavior (launchd)
- The app installs a peruser LaunchAgent labeled `com.clawdbot.gateway`.
- The app installs a peruser LaunchAgent labeled `com.clawdbot.gateway`
(or `com.clawdbot.<profile>` when using `--profile`/`CLAWDBOT_PROFILE`).
- When Local mode is enabled, the app ensures the LaunchAgent is loaded and
starts the Gateway if needed.
- Logs are written to the launchd gateway log path (visible in Debug Settings).
@@ -26,6 +27,8 @@ launchctl kickstart -k gui/$UID/com.clawdbot.gateway
launchctl bootout gui/$UID/com.clawdbot.gateway
```
Replace the label with `com.clawdbot.<profile>` when running a named profile.
## Attachonly (developer mode)
Attachonly tells the app to **connect to an existing Gateway** without spawning

View File

@@ -31,13 +31,16 @@ node.
## Launchd control
The app manages a peruser LaunchAgent labeled `com.clawdbot.gateway`.
The app manages a peruser LaunchAgent labeled `com.clawdbot.gateway`
(or `com.clawdbot.<profile>` when using `--profile`/`CLAWDBOT_PROFILE`).
```bash
launchctl kickstart -k gui/$UID/com.clawdbot.gateway
launchctl bootout gui/$UID/com.clawdbot.gateway
```
Replace the label with `com.clawdbot.<profile>` when running a named profile.
If the LaunchAgent isnt installed, enable it from the app or run
`clawdbot daemon install`.

View File

@@ -606,6 +606,8 @@ Yes, but you must isolate:
- `gateway.port` (unique ports)
There are convenience CLI flags like `--dev` and `--profile <name>` that shift state dirs and ports.
When using profiles, service names are suffixed (`com.clawdbot.<profile>`, `clawdbot-gateway-<profile>.service`,
`Clawdbot Gateway (<profile>)`).
## Logging and debugging
@@ -627,8 +629,8 @@ clawdbot logs --follow
Service/supervisor logs (when the gateway runs via launchd/systemd):
- macOS: `$CLAWDBOT_STATE_DIR/logs/gateway.log` and `gateway.err.log` (default: `~/.clawdbot/logs/...`; profiles use `~/.clawdbot-<profile>/logs/...`)
- Linux: `journalctl --user -u clawdbot-gateway.service -n 200 --no-pager`
- Windows: `schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST`
- Linux: `journalctl --user -u clawdbot-gateway[-<profile>].service -n 200 --no-pager`
- Windows: `schtasks /Query /TN "Clawdbot Gateway (<profile>)" /V /FO LIST`
See [Troubleshooting](/gateway/troubleshooting#log-locations) for more.

View File

@@ -18,6 +18,8 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe
commands: {
native: false,
text: true,
config: false,
debug: false,
restart: false,
useAccessGroups: true
}
@@ -29,6 +31,8 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe
- `commands.native` (default `false`) registers native commands on Discord/Slack/Telegram.
- `false` clears previously registered commands on Discord/Telegram at startup.
- Slack commands are managed in the Slack app and are not removed automatically.
- `commands.config` (default `false`) enables `/config` (reads/writes `clawdbot.json`).
- `commands.debug` (default `false`) enables `/debug` (runtime-only overrides).
- `commands.useAccessGroups` (default `true`) enforces allowlists/policies for commands.
## Command list
@@ -39,8 +43,8 @@ Text + native (when enabled):
- `/status`
- `/status` (show current status; includes a short usage line when available)
- `/usage` (alias: `/status`)
- `/config show|get|set|unset` (persist config to disk, owner-only)
- `/debug show|set|unset|reset` (runtime overrides, owner-only)
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
- `/cost on|off` (toggle per-response usage line)
- `/stop`
- `/restart`
@@ -67,7 +71,7 @@ Notes:
## Debug overrides
`/debug` lets you set **runtime-only** config overrides (memory, not disk). Owner-only.
`/debug` lets you set **runtime-only** config overrides (memory, not disk). Owner-only. Disabled by default; enable with `commands.debug: true`.
Examples:
@@ -85,7 +89,7 @@ Notes:
## Config updates
`/config` writes to your on-disk config (`clawdbot.json`). Owner-only.
`/config` writes to your on-disk config (`clawdbot.json`). Owner-only. Disabled by default; enable with `commands.config: true`.
Examples:

View File

@@ -57,6 +57,12 @@ describe("control command parsing", () => {
}
});
it("respects disabled config/debug commands", () => {
const cfg = { commands: { config: false, debug: false } };
expect(hasControlCommand("/config show", cfg)).toBe(false);
expect(hasControlCommand("/debug show", cfg)).toBe(false);
});
it("requires commands to be the full message", () => {
expect(hasControlCommand("hello /status")).toBe(false);
expect(hasControlCommand("/status please")).toBe(false);

View File

@@ -1,13 +1,22 @@
import { listChatCommands, normalizeCommandBody } from "./commands-registry.js";
import type { ClawdbotConfig } from "../config/types.js";
import {
listChatCommands,
listChatCommandsForConfig,
normalizeCommandBody,
} from "./commands-registry.js";
export function hasControlCommand(text?: string): boolean {
export function hasControlCommand(
text?: string,
cfg?: ClawdbotConfig,
): boolean {
if (!text) return false;
const trimmed = text.trim();
if (!trimmed) return false;
const normalizedBody = normalizeCommandBody(trimmed);
if (!normalizedBody) return false;
const lowered = normalizedBody.toLowerCase();
for (const command of listChatCommands()) {
const commands = cfg ? listChatCommandsForConfig(cfg) : listChatCommands();
for (const command of commands) {
for (const alias of command.textAliases) {
const normalized = alias.trim().toLowerCase();
if (!normalized) continue;

View File

@@ -4,7 +4,9 @@ import {
buildCommandText,
getCommandDetection,
listChatCommands,
listChatCommandsForConfig,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
shouldHandleTextCommands,
} from "./commands-registry.js";
@@ -21,6 +23,26 @@ describe("commands registry", () => {
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
});
it("filters commands based on config flags", () => {
const disabled = listChatCommandsForConfig({
commands: { config: false, debug: false },
});
expect(disabled.find((spec) => spec.key === "config")).toBeFalsy();
expect(disabled.find((spec) => spec.key === "debug")).toBeFalsy();
const enabled = listChatCommandsForConfig({
commands: { config: true, debug: true },
});
expect(enabled.find((spec) => spec.key === "config")).toBeTruthy();
expect(enabled.find((spec) => spec.key === "debug")).toBeTruthy();
const nativeDisabled = listNativeCommandSpecsForConfig({
commands: { config: false, debug: false, native: true },
});
expect(nativeDisabled.find((spec) => spec.name === "config")).toBeFalsy();
expect(nativeDisabled.find((spec) => spec.name === "debug")).toBeFalsy();
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/commands")).toBe(true);

View File

@@ -290,6 +290,21 @@ export function listChatCommands(): ChatCommandDefinition[] {
return [...CHAT_COMMANDS];
}
export function isCommandEnabled(
cfg: ClawdbotConfig,
commandKey: string,
): boolean {
if (commandKey === "config") return cfg.commands?.config === true;
if (commandKey === "debug") return cfg.commands?.debug === true;
return true;
}
export function listChatCommandsForConfig(
cfg: ClawdbotConfig,
): ChatCommandDefinition[] {
return CHAT_COMMANDS.filter((command) => isCommandEnabled(cfg, command.key));
}
export function listNativeCommandSpecs(): NativeCommandSpec[] {
return CHAT_COMMANDS.filter(
(command) => command.scope !== "text" && command.nativeName,
@@ -300,6 +315,18 @@ export function listNativeCommandSpecs(): NativeCommandSpec[] {
}));
}
export function listNativeCommandSpecsForConfig(
cfg: ClawdbotConfig,
): NativeCommandSpec[] {
return listChatCommandsForConfig(cfg)
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({
name: command.nativeName ?? command.key,
description: command.description,
acceptsArgs: Boolean(command.acceptsArgs),
}));
}
export function findCommandByNativeName(
name: string,
): ChatCommandDefinition | undefined {

View File

@@ -877,7 +877,7 @@ export async function getReplyFromConfig(
allowTextCommands &&
!commandAuthorized &&
!baseBodyTrimmedRaw &&
hasControlCommand(commandSource)
hasControlCommand(commandSource, cfg)
) {
typing.cleanup();
return undefined;

View File

@@ -602,7 +602,7 @@ export async function handleCommands(params: {
);
return { shouldContinue: false };
}
return { shouldContinue: false, reply: { text: buildHelpMessage() } };
return { shouldContinue: false, reply: { text: buildHelpMessage(cfg) } };
}
const commandsRequested = command.commandBodyNormalized === "/commands";
@@ -613,7 +613,7 @@ export async function handleCommands(params: {
);
return { shouldContinue: false };
}
return { shouldContinue: false, reply: { text: buildCommandsMessage() } };
return { shouldContinue: false, reply: { text: buildCommandsMessage(cfg) } };
}
const statusRequested =
@@ -650,6 +650,14 @@ export async function handleCommands(params: {
);
return { shouldContinue: false };
}
if (cfg.commands?.config !== true) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /config is disabled. Set commands.config=true to enable.",
},
};
}
if (configCommand.action === "error") {
return {
shouldContinue: false,
@@ -774,6 +782,14 @@ export async function handleCommands(params: {
);
return { shouldContinue: false };
}
if (cfg.commands?.debug !== true) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /debug is disabled. Set commands.debug=true to enable.",
},
};
}
if (debugCommand.action === "error") {
return {
shouldContinue: false,

View File

@@ -4,7 +4,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js";
import { buildCommandsMessage, buildStatusMessage } from "./status.js";
import {
buildCommandsMessage,
buildHelpMessage,
buildStatusMessage,
} from "./status.js";
afterEach(() => {
vi.restoreAllMocks();
@@ -317,7 +321,9 @@ describe("buildStatusMessage", () => {
describe("buildCommandsMessage", () => {
it("lists commands with aliases and text-only hints", () => {
const text = buildCommandsMessage();
const text = buildCommandsMessage({
commands: { config: false, debug: false },
} as ClawdbotConfig);
expect(text).toContain("/commands - List all slash commands.");
expect(text).toContain(
"/think (aliases: /thinking, /t) - Set thinking level.",
@@ -325,5 +331,17 @@ describe("buildCommandsMessage", () => {
expect(text).toContain(
"/compact (text-only) - Compact the session context.",
);
expect(text).not.toContain("/config");
expect(text).not.toContain("/debug");
});
});
describe("buildHelpMessage", () => {
it("hides config/debug when disabled", () => {
const text = buildHelpMessage({
commands: { config: false, debug: false },
} as ClawdbotConfig);
expect(text).not.toContain("/config");
expect(text).not.toContain("/debug");
});
});

View File

@@ -28,7 +28,10 @@ import {
resolveModelCostConfig,
} from "../utils/usage-format.js";
import { VERSION } from "../version.js";
import { listChatCommands } from "./commands-registry.js";
import {
listChatCommands,
listChatCommandsForConfig,
} from "./commands-registry.js";
import type {
ElevatedLevel,
ReasoningLevel,
@@ -356,18 +359,29 @@ export function buildStatusMessage(args: StatusArgs): string {
.join("\n");
}
export function buildHelpMessage(): string {
export function buildHelpMessage(cfg?: ClawdbotConfig): string {
const options = [
"/think <level>",
"/verbose on|off",
"/reasoning on|off",
"/elevated on|off",
"/model <id>",
"/cost on|off",
];
if (cfg?.commands?.config === true) options.push("/config show");
if (cfg?.commands?.debug === true) options.push("/debug show");
return [
" Help",
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off | /config show | /debug show",
`Options: ${options.join(" | ")}`,
"More: /commands for all slash commands",
].join("\n");
}
export function buildCommandsMessage(): string {
export function buildCommandsMessage(cfg?: ClawdbotConfig): string {
const lines = [" Slash commands"];
for (const command of listChatCommands()) {
const commands = cfg ? listChatCommandsForConfig(cfg) : listChatCommands();
for (const command of commands) {
const primary = command.nativeName
? `/${command.nativeName}`
: command.textAliases[0]?.trim() || `/${command.key}`;

View File

@@ -19,9 +19,9 @@ import type {
GatewayControlUiConfig,
} from "../config/types.js";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
GATEWAY_SYSTEMD_SERVICE_NAME,
GATEWAY_WINDOWS_TASK_NAME,
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
} from "../daemon/constants.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import {
@@ -309,31 +309,42 @@ function renderRuntimeHints(
hints.push(`Launchd stdout (if installed): ${logs.stdoutPath}`);
hints.push(`Launchd stderr (if installed): ${logs.stderrPath}`);
} else if (process.platform === "linux") {
const unit = resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
hints.push(
"Logs: journalctl --user -u clawdbot-gateway.service -n 200 --no-pager",
`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`,
);
} else if (process.platform === "win32") {
hints.push('Logs: schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST');
const task = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
hints.push(`Logs: schtasks /Query /TN "${task}" /V /FO LIST`);
}
}
return hints;
}
function renderGatewayServiceStartHints(): string[] {
function renderGatewayServiceStartHints(
env: NodeJS.ProcessEnv = process.env,
): string[] {
const base = ["clawdbot daemon install", "clawdbot gateway"];
const profile = env.CLAWDBOT_PROFILE;
switch (process.platform) {
case "darwin":
case "darwin": {
const label = resolveGatewayLaunchAgentLabel(profile);
return [
...base,
`launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${GATEWAY_LAUNCH_AGENT_LABEL}.plist`,
`launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${label}.plist`,
];
case "linux":
}
case "linux": {
const unit = resolveGatewaySystemdServiceName(profile);
return [
...base,
`systemctl --user start ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`,
`systemctl --user start ${unit}.service`,
];
case "win32":
return [...base, `schtasks /Run /TN "${GATEWAY_WINDOWS_TASK_NAME}"`];
}
case "win32": {
const task = resolveGatewayWindowsTaskName(profile);
return [...base, `schtasks /Run /TN "${task}"`];
}
default:
return base;
}
@@ -346,7 +357,9 @@ async function gatherDaemonStatus(opts: {
}): Promise<DaemonStatus> {
const service = resolveGatewayService();
const [loaded, command, runtime] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false),
service
.isLoaded({ profile: process.env.CLAWDBOT_PROFILE })
.catch(() => false),
service.readCommand(process.env).catch(() => null),
service.readRuntime(process.env).catch(() => undefined),
]);
@@ -713,9 +726,11 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
spacer();
}
if (service.runtime?.cachedLabel) {
const env = (service.command?.environment ?? process.env) as NodeJS.ProcessEnv;
const label = resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
defaultRuntime.error(
errorText(
`LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
`LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${label}`,
),
);
defaultRuntime.error(errorText("Then reinstall: clawdbot daemon install"));
@@ -767,9 +782,11 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
);
}
if (process.platform === "linux") {
const env = (service.command?.environment ?? process.env) as NodeJS.ProcessEnv;
const unit = resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
defaultRuntime.error(
errorText(
`Logs: journalctl --user -u ${GATEWAY_SYSTEMD_SERVICE_NAME}.service -n 200 --no-pager`,
`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`,
),
);
} else if (process.platform === "darwin") {
@@ -872,9 +889,10 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
}
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
loaded = await service.isLoaded({ profile });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -910,7 +928,9 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
cfg.gateway?.auth?.token ||
process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(profile)
: undefined,
});
try {
@@ -945,9 +965,10 @@ 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({ env: process.env });
loaded = await service.isLoaded({ profile });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -961,7 +982,7 @@ export async function runDaemonStart() {
return;
}
try {
await service.restart({ stdout: process.stdout });
await service.restart({ profile, stdout: process.stdout });
} catch (err) {
defaultRuntime.error(`Gateway start failed: ${String(err)}`);
for (const hint of renderGatewayServiceStartHints()) {
@@ -973,9 +994,10 @@ 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({ env: process.env });
loaded = await service.isLoaded({ profile });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -986,7 +1008,7 @@ export async function runDaemonStop() {
return;
}
try {
await service.stop({ stdout: process.stdout });
await service.stop({ profile, stdout: process.stdout });
} catch (err) {
defaultRuntime.error(`Gateway stop failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -1000,9 +1022,10 @@ 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({ env: process.env });
loaded = await service.isLoaded({ profile });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -1016,7 +1039,7 @@ export async function runDaemonRestart(): Promise<boolean> {
return false;
}
try {
await service.restart({ stdout: process.stdout });
await service.restart({ profile, stdout: process.stdout });
return true;
} catch (err) {
defaultRuntime.error(`Gateway restart failed: ${String(err)}`);

View File

@@ -15,9 +15,9 @@ import {
writeConfigFile,
} from "../config/config.js";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
GATEWAY_SYSTEMD_SERVICE_NAME,
GATEWAY_WINDOWS_TASK_NAME,
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
} from "../daemon/constants.js";
import { resolveGatewayService } from "../daemon/service.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
@@ -362,22 +362,25 @@ function extractGatewayMiskeys(parsed: unknown): {
return { hasGatewayToken, hasRemoteToken };
}
function renderGatewayServiceStopHints(): string[] {
function renderGatewayServiceStopHints(
env: NodeJS.ProcessEnv = process.env,
): string[] {
const profile = env.CLAWDBOT_PROFILE;
switch (process.platform) {
case "darwin":
return [
"Tip: clawdbot daemon stop",
`Or: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
`Or: launchctl bootout gui/$UID/${resolveGatewayLaunchAgentLabel(profile)}`,
];
case "linux":
return [
"Tip: clawdbot daemon stop",
`Or: systemctl --user stop ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`,
`Or: systemctl --user stop ${resolveGatewaySystemdServiceName(profile)}.service`,
];
case "win32":
return [
"Tip: clawdbot daemon stop",
`Or: schtasks /End /TN "${GATEWAY_WINDOWS_TASK_NAME}"`,
`Or: schtasks /End /TN "${resolveGatewayWindowsTaskName(profile)}"`,
];
default:
return ["Tip: clawdbot daemon stop"];
@@ -388,7 +391,7 @@ async function maybeExplainGatewayServiceStop() {
const service = resolveGatewayService();
let loaded: boolean | null = null;
try {
loaded = await service.isLoaded({ env: process.env });
loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
} catch {
loaded = null;
}

View File

@@ -17,7 +17,7 @@ import {
resolveGatewayPort,
writeConfigFile,
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
@@ -340,7 +340,9 @@ async function maybeInstallDaemon(params: {
daemonRuntime?: GatewayDaemonRuntime;
}) {
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
const loaded = await service.isLoaded({
profile: process.env.CLAWDBOT_PROFILE,
});
let shouldCheckLinger = false;
let shouldInstall = true;
let daemonRuntime = params.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME;
@@ -357,7 +359,10 @@ async function maybeInstallDaemon(params: {
params.runtime,
);
if (action === "restart") {
await service.restart({ stdout: process.stdout });
await service.restart({
profile: process.env.CLAWDBOT_PROFILE,
stdout: process.stdout,
});
shouldCheckLinger = true;
shouldInstall = false;
}
@@ -397,7 +402,9 @@ async function maybeInstallDaemon(params: {
port: params.port,
token: params.gatewayToken,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
: undefined,
});
await service.install({
env: process.env,

View File

@@ -1,4 +1,8 @@
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import {
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
} from "../daemon/constants.js";
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
import type { GatewayServiceRuntime } from "../daemon/service-runtime.js";
import { getResolvedLoggerSettings } from "../logging.js";
@@ -51,8 +55,9 @@ export function buildGatewayRuntimeHints(
}
})();
if (runtime.cachedLabel && platform === "darwin") {
const label = resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
hints.push(
`LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
`LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${label}`,
);
hints.push("Then reinstall: clawdbot daemon install");
}
@@ -71,11 +76,13 @@ export function buildGatewayRuntimeHints(
hints.push(`Launchd stdout (if installed): ${logs.stdoutPath}`);
hints.push(`Launchd stderr (if installed): ${logs.stderrPath}`);
} else if (platform === "linux") {
const unit = resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
hints.push(
"Logs: journalctl --user -u clawdbot-gateway.service -n 200 --no-pager",
`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`,
);
} else if (platform === "win32") {
hints.push('Logs: schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST');
const task = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
hints.push(`Logs: schtasks /Query /TN "${task}" /V /FO LIST`);
}
}
return hints;

View File

@@ -4,7 +4,7 @@ import { note as clackNote } from "@clack/prompts";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
import {
findExtraGatewayServices,
renderGatewayServiceCleanupHints,
@@ -103,7 +103,9 @@ export async function maybeMigrateLegacyGatewayService(
}
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
const loaded = await service.isLoaded({
profile: process.env.CLAWDBOT_PROFILE,
});
if (loaded) {
note(`Clawdbot ${service.label} already ${service.loadedText}.`, "Gateway");
return;
@@ -143,7 +145,9 @@ export async function maybeMigrateLegacyGatewayService(
port,
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
: undefined,
});
await service.install({
env: process.env,
@@ -263,7 +267,9 @@ export async function maybeRepairGatewayServiceConfig(
port,
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
: undefined,
});
try {

View File

@@ -24,7 +24,7 @@ import {
resolveGatewayPort,
writeConfigFile,
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
@@ -421,7 +421,9 @@ export async function doctorCommand(
const service = resolveGatewayService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
loaded = await service.isLoaded({
profile: process.env.CLAWDBOT_PROFILE,
});
} catch {
loaded = false;
}
@@ -503,7 +505,9 @@ export async function doctorCommand(
if (!healthOk) {
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
const loaded = await service.isLoaded({
profile: process.env.CLAWDBOT_PROFILE,
});
let serviceRuntime:
| Awaited<ReturnType<typeof service.readRuntime>>
| undefined;
@@ -562,7 +566,7 @@ export async function doctorCommand(
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel:
process.platform === "darwin"
? GATEWAY_LAUNCH_AGENT_LABEL
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
: undefined,
});
await service.install({
@@ -592,13 +596,19 @@ export async function doctorCommand(
initialValue: true,
});
if (start) {
await service.restart({ stdout: process.stdout });
await service.restart({
profile: process.env.CLAWDBOT_PROFILE,
stdout: process.stdout,
});
await sleep(1500);
}
}
if (process.platform === "darwin") {
const label = resolveGatewayLaunchAgentLabel(
process.env.CLAWDBOT_PROFILE,
);
note(
`LaunchAgent loaded; stopping requires "clawdbot daemon stop" or launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}.`,
`LaunchAgent loaded; stopping requires "clawdbot daemon stop" or launchctl bootout gui/$UID/${label}.`,
"Gateway",
);
}
@@ -608,7 +618,10 @@ export async function doctorCommand(
initialValue: true,
});
if (restart) {
await service.restart({ stdout: process.stdout });
await service.restart({
profile: process.env.CLAWDBOT_PROFILE,
stdout: process.stdout,
});
await sleep(1500);
try {
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);

View File

@@ -14,7 +14,7 @@ import {
resolveGatewayPort,
writeConfigFile,
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
@@ -505,15 +505,15 @@ export async function runNonInteractiveOnboarding(
runtime: daemonRuntimeRaw,
nodePath,
});
const environment = buildServiceEnvironment({
env: process.env,
port,
token: gatewayToken,
launchdLabel:
process.platform === "darwin"
? GATEWAY_LAUNCH_AGENT_LABEL
: undefined,
});
const environment = buildServiceEnvironment({
env: process.env,
port,
token: gatewayToken,
launchdLabel:
process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
: undefined,
});
await service.install({
env: process.env,
stdout: process.stdout,

View File

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

View File

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

View File

@@ -14,6 +14,8 @@ describe("config paths", () => {
error: "Invalid path. Use dot notation (e.g. foo.bar).",
});
expect(parseConfigPath("__proto__.polluted").ok).toBe(false);
expect(parseConfigPath("constructor.polluted").ok).toBe(false);
expect(parseConfigPath("prototype.polluted").ok).toBe(false);
});
it("sets, gets, and unsets nested values", () => {

View File

@@ -39,4 +39,17 @@ describe("runtime overrides", () => {
expect(removed.removed).toBe(true);
expect(Object.keys(getConfigOverrides()).length).toBe(0);
});
it("rejects prototype pollution paths", () => {
const attempts = [
"__proto__.polluted",
"constructor.polluted",
"prototype.polluted",
];
for (const path of attempts) {
const result = setConfigOverride(path, true);
expect(result.ok).toBe(false);
expect(Object.keys(getConfigOverrides()).length).toBe(0);
}
});
});

View File

@@ -115,6 +115,8 @@ const FIELD_LABELS: Record<string, string> = {
"agents.defaults.cliBackends": "CLI Backends",
"commands.native": "Native Commands",
"commands.text": "Text Commands",
"commands.config": "Allow /config",
"commands.debug": "Allow /debug",
"commands.restart": "Allow Restart",
"commands.useAccessGroups": "Use Access Groups",
"ui.seamColor": "Accent Color",
@@ -203,6 +205,10 @@ const FIELD_HELP: Record<string, string> = {
"commands.native":
"Register native commands with connectors that support it (Discord/Slack/Telegram).",
"commands.text": "Allow text command parsing (slash commands only).",
"commands.config":
"Allow /config chat command to read/write config on disk (default: false).",
"commands.debug":
"Allow /debug chat command for runtime-only overrides (default: false).",
"commands.restart":
"Allow /restart and gateway restart tool actions (default: false).",
"commands.useAccessGroups":

View File

@@ -1078,6 +1078,10 @@ export type CommandsConfig = {
native?: boolean;
/** Enable text command parsing (default: true). */
text?: boolean;
/** Allow /config command (default: false). */
config?: boolean;
/** Allow /debug command (default: false). */
debug?: boolean;
/** Allow restart commands/tools (default: false). */
restart?: boolean;
/** Enforce access-group allowlists/policies for commands (default: true). */

View File

@@ -685,6 +685,8 @@ const CommandsSchema = z
.object({
native: z.boolean().optional(),
text: z.boolean().optional(),
config: z.boolean().optional(),
debug: z.boolean().optional(),
restart: z.boolean().optional(),
useAccessGroups: z.boolean().optional(),
})

View File

@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
GATEWAY_SYSTEMD_SERVICE_NAME,
GATEWAY_WINDOWS_TASK_NAME,
formatGatewayServiceDescription,
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
} from "./constants.js";
describe("resolveGatewayLaunchAgentLabel", () => {
it("returns default label when no profile is set", () => {
const result = resolveGatewayLaunchAgentLabel();
expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL);
expect(result).toBe("com.clawdbot.gateway");
});
it("returns default label when profile is undefined", () => {
const result = resolveGatewayLaunchAgentLabel(undefined);
expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL);
});
it("returns default label when profile is 'default'", () => {
const result = resolveGatewayLaunchAgentLabel("default");
expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL);
});
it("returns default label when profile is 'Default' (case-insensitive)", () => {
const result = resolveGatewayLaunchAgentLabel("Default");
expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL);
});
it("returns profile-specific label when profile is set", () => {
const result = resolveGatewayLaunchAgentLabel("dev");
expect(result).toBe("com.clawdbot.dev");
});
it("returns profile-specific label for custom profile", () => {
const result = resolveGatewayLaunchAgentLabel("work");
expect(result).toBe("com.clawdbot.work");
});
it("trims whitespace from profile", () => {
const result = resolveGatewayLaunchAgentLabel(" staging ");
expect(result).toBe("com.clawdbot.staging");
});
it("returns default label for empty string profile", () => {
const result = resolveGatewayLaunchAgentLabel("");
expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL);
});
it("returns default label for whitespace-only profile", () => {
const result = resolveGatewayLaunchAgentLabel(" ");
expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL);
});
});
describe("resolveGatewaySystemdServiceName", () => {
it("returns default service name when no profile is set", () => {
const result = resolveGatewaySystemdServiceName();
expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME);
expect(result).toBe("clawdbot-gateway");
});
it("returns default service name when profile is undefined", () => {
const result = resolveGatewaySystemdServiceName(undefined);
expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME);
});
it("returns default service name when profile is 'default'", () => {
const result = resolveGatewaySystemdServiceName("default");
expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME);
});
it("returns default service name when profile is 'DEFAULT' (case-insensitive)", () => {
const result = resolveGatewaySystemdServiceName("DEFAULT");
expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME);
});
it("returns profile-specific service name when profile is set", () => {
const result = resolveGatewaySystemdServiceName("dev");
expect(result).toBe("clawdbot-gateway-dev");
});
it("returns profile-specific service name for custom profile", () => {
const result = resolveGatewaySystemdServiceName("production");
expect(result).toBe("clawdbot-gateway-production");
});
it("trims whitespace from profile", () => {
const result = resolveGatewaySystemdServiceName(" test ");
expect(result).toBe("clawdbot-gateway-test");
});
it("returns default service name for empty string profile", () => {
const result = resolveGatewaySystemdServiceName("");
expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME);
});
});
describe("resolveGatewayWindowsTaskName", () => {
it("returns default task name when no profile is set", () => {
const result = resolveGatewayWindowsTaskName();
expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME);
expect(result).toBe("Clawdbot Gateway");
});
it("returns default task name when profile is undefined", () => {
const result = resolveGatewayWindowsTaskName(undefined);
expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME);
});
it("returns default task name when profile is 'default'", () => {
const result = resolveGatewayWindowsTaskName("default");
expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME);
});
it("returns default task name when profile is 'DeFaUlT' (case-insensitive)", () => {
const result = resolveGatewayWindowsTaskName("DeFaUlT");
expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME);
});
it("returns profile-specific task name when profile is set", () => {
const result = resolveGatewayWindowsTaskName("dev");
expect(result).toBe("Clawdbot Gateway (dev)");
});
it("returns profile-specific task name for custom profile", () => {
const result = resolveGatewayWindowsTaskName("work");
expect(result).toBe("Clawdbot Gateway (work)");
});
it("trims whitespace from profile", () => {
const result = resolveGatewayWindowsTaskName(" ci ");
expect(result).toBe("Clawdbot Gateway (ci)");
});
it("returns default task name for empty string profile", () => {
const result = resolveGatewayWindowsTaskName("");
expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME);
});
});
describe("formatGatewayServiceDescription", () => {
it("returns default description when no profile/version", () => {
expect(formatGatewayServiceDescription()).toBe("Clawdbot Gateway");
});
it("includes profile when set", () => {
expect(
formatGatewayServiceDescription({ profile: "work" }),
).toBe("Clawdbot Gateway (profile: work)");
});
it("includes version when set", () => {
expect(
formatGatewayServiceDescription({ version: "2026.1.10" }),
).toBe("Clawdbot Gateway (v2026.1.10)");
});
it("includes profile and version when set", () => {
expect(
formatGatewayServiceDescription({ profile: "dev", version: "1.2.3" }),
).toBe("Clawdbot Gateway (profile: dev, v1.2.3)");
});
});

View File

@@ -1,6 +1,8 @@
export const GATEWAY_LAUNCH_AGENT_LABEL = "com.clawdbot.gateway";
export const GATEWAY_SYSTEMD_SERVICE_NAME = "clawdbot-gateway";
export const GATEWAY_WINDOWS_TASK_NAME = "Clawdbot Gateway";
export const GATEWAY_SERVICE_MARKER = "clawdbot";
export const GATEWAY_SERVICE_KIND = "gateway";
export const LEGACY_GATEWAY_LAUNCH_AGENT_LABELS = [
"com.steipete.clawdbot.gateway",
"com.steipete.clawdis.gateway",
@@ -8,3 +10,46 @@ export const LEGACY_GATEWAY_LAUNCH_AGENT_LABELS = [
];
export const LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES = ["clawdis-gateway"];
export const LEGACY_GATEWAY_WINDOWS_TASK_NAMES = ["Clawdis Gateway"];
export function resolveGatewayLaunchAgentLabel(profile?: string): string {
const trimmed = profile?.trim();
if (!trimmed || trimmed.toLowerCase() === "default") {
return GATEWAY_LAUNCH_AGENT_LABEL;
}
return `com.clawdbot.${trimmed}`;
}
function normalizeGatewayProfile(profile?: string): string | null {
const trimmed = profile?.trim();
if (!trimmed || trimmed.toLowerCase() === "default") return null;
return trimmed;
}
export function resolveGatewaySystemdServiceName(profile?: string): string {
const trimmed = profile?.trim();
if (!trimmed || trimmed.toLowerCase() === "default") {
return GATEWAY_SYSTEMD_SERVICE_NAME;
}
return `clawdbot-gateway-${trimmed}`;
}
export function resolveGatewayWindowsTaskName(profile?: string): string {
const trimmed = profile?.trim();
if (!trimmed || trimmed.toLowerCase() === "default") {
return GATEWAY_WINDOWS_TASK_NAME;
}
return `Clawdbot Gateway (${trimmed})`;
}
export function formatGatewayServiceDescription(params?: {
profile?: string;
version?: string;
}): string {
const profile = normalizeGatewayProfile(params?.profile);
const version = params?.version?.trim();
const parts: string[] = [];
if (profile) parts.push(`profile: ${profile}`);
if (version) parts.push(`v${version}`);
if (parts.length === 0) return "Clawdbot Gateway";
return `Clawdbot Gateway (${parts.join(", ")})`;
}

View File

@@ -4,12 +4,14 @@ import path from "node:path";
import { promisify } from "node:util";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
GATEWAY_SYSTEMD_SERVICE_NAME,
GATEWAY_WINDOWS_TASK_NAME,
GATEWAY_SERVICE_KIND,
GATEWAY_SERVICE_MARKER,
LEGACY_GATEWAY_LAUNCH_AGENT_LABELS,
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
LEGACY_GATEWAY_WINDOWS_TASK_NAMES,
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
} from "./constants.js";
export type ExtraGatewayService = {
@@ -26,20 +28,32 @@ export type FindExtraGatewayServicesOptions = {
const EXTRA_MARKERS = ["clawdbot", "clawdis"];
const execFileAsync = promisify(execFile);
export function renderGatewayServiceCleanupHints(): string[] {
export function renderGatewayServiceCleanupHints(
env: Record<string, string | undefined> = process.env as Record<
string,
string | undefined
>,
): string[] {
const profile = env.CLAWDBOT_PROFILE;
switch (process.platform) {
case "darwin":
case "darwin": {
const label = resolveGatewayLaunchAgentLabel(profile);
return [
`launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
`rm ~/Library/LaunchAgents/${GATEWAY_LAUNCH_AGENT_LABEL}.plist`,
`launchctl bootout gui/$UID/${label}`,
`rm ~/Library/LaunchAgents/${label}.plist`,
];
case "linux":
}
case "linux": {
const unit = resolveGatewaySystemdServiceName(profile);
return [
`systemctl --user disable --now ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`,
`rm ~/.config/systemd/user/${GATEWAY_SYSTEMD_SERVICE_NAME}.service`,
`systemctl --user disable --now ${unit}.service`,
`rm ~/.config/systemd/user/${unit}.service`,
];
case "win32":
return [`schtasks /Delete /TN "${GATEWAY_WINDOWS_TASK_NAME}" /F`];
}
case "win32": {
const task = resolveGatewayWindowsTaskName(profile);
return [`schtasks /Delete /TN "${task}" /F`];
}
default:
return [];
}
@@ -56,6 +70,42 @@ function containsMarker(content: string): boolean {
return EXTRA_MARKERS.some((marker) => lower.includes(marker));
}
function hasGatewayServiceMarker(content: string): boolean {
const lower = content.toLowerCase();
return (
lower.includes("clawdbot_service_marker") &&
lower.includes(GATEWAY_SERVICE_MARKER.toLowerCase()) &&
lower.includes("clawdbot_service_kind") &&
lower.includes(GATEWAY_SERVICE_KIND.toLowerCase())
);
}
function isClawdbotGatewayLaunchdService(
label: string,
contents: string,
): boolean {
if (hasGatewayServiceMarker(contents)) return true;
const lowerContents = contents.toLowerCase();
if (!lowerContents.includes("gateway")) return false;
return label.startsWith("com.clawdbot.");
}
function isClawdbotGatewaySystemdService(
name: string,
contents: string,
): boolean {
if (hasGatewayServiceMarker(contents)) return true;
if (!name.startsWith("clawdbot-gateway")) return false;
return contents.toLowerCase().includes("gateway");
}
function isClawdbotGatewayTaskName(name: string): boolean {
const normalized = name.trim().toLowerCase();
if (!normalized) return false;
const defaultName = resolveGatewayWindowsTaskName().toLowerCase();
return normalized === defaultName || normalized.startsWith("clawdbot gateway");
}
function tryExtractPlistLabel(contents: string): string | null {
const match = contents.match(
/<key>Label<\/key>\s*<string>([\s\S]*?)<\/string>/i,
@@ -66,14 +116,14 @@ function tryExtractPlistLabel(contents: string): string | null {
function isIgnoredLaunchdLabel(label: string): boolean {
return (
label === GATEWAY_LAUNCH_AGENT_LABEL ||
label === resolveGatewayLaunchAgentLabel() ||
LEGACY_GATEWAY_LAUNCH_AGENT_LABELS.includes(label)
);
}
function isIgnoredSystemdName(name: string): boolean {
return (
name === GATEWAY_SYSTEMD_SERVICE_NAME ||
name === resolveGatewaySystemdServiceName() ||
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES.includes(name)
);
}
@@ -104,6 +154,7 @@ async function scanLaunchdDir(params: {
if (!containsMarker(contents)) continue;
const label = tryExtractPlistLabel(contents) ?? labelFromName;
if (isIgnoredLaunchdLabel(label)) continue;
if (isClawdbotGatewayLaunchdService(label, contents)) continue;
results.push({
platform: "darwin",
label,
@@ -139,6 +190,7 @@ async function scanSystemdDir(params: {
continue;
}
if (!containsMarker(contents)) continue;
if (isClawdbotGatewaySystemdService(name, contents)) continue;
results.push({
platform: "linux",
label: entry,
@@ -302,7 +354,7 @@ export async function findExtraGatewayServices(
for (const task of tasks) {
const name = task.name.trim();
if (!name) continue;
if (name === GATEWAY_WINDOWS_TASK_NAME) continue;
if (isClawdbotGatewayTaskName(name)) continue;
if (LEGACY_GATEWAY_WINDOWS_TASK_NAMES.includes(name)) continue;
const lowerName = name.toLowerCase();
const lowerCommand = task.taskToRun?.toLowerCase() ?? "";

View File

@@ -7,6 +7,8 @@ import { colorize, isRich, theme } from "../terminal/theme.js";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
LEGACY_GATEWAY_LAUNCH_AGENT_LABELS,
formatGatewayServiceDescription,
resolveGatewayLaunchAgentLabel,
} from "./constants.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
@@ -34,7 +36,10 @@ function resolveLaunchAgentPlistPathForLabel(
export function resolveLaunchAgentPlistPath(
env: Record<string, string | undefined>,
): string {
return resolveLaunchAgentPlistPathForLabel(env, GATEWAY_LAUNCH_AGENT_LABEL);
const label =
env.CLAWDBOT_LAUNCHD_LABEL?.trim() ||
resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
return resolveLaunchAgentPlistPathForLabel(env, label);
}
export function resolveGatewayLogPaths(
@@ -162,6 +167,7 @@ export async function readLaunchAgentProgramArguments(
export function buildLaunchAgentPlist({
label = GATEWAY_LAUNCH_AGENT_LABEL,
comment,
programArguments,
workingDirectory,
stdoutPath,
@@ -169,6 +175,7 @@ export function buildLaunchAgentPlist({
environment,
}: {
label?: string;
comment?: string;
programArguments: string[];
workingDirectory?: string;
stdoutPath: string;
@@ -183,6 +190,12 @@ export function buildLaunchAgentPlist({
<key>WorkingDirectory</key>
<string>${plistEscape(workingDirectory)}</string>`
: "";
const commentXml =
comment && comment.trim()
? `
<key>Comment</key>
<string>${plistEscape(comment.trim())}</string>`
: "";
const envXml = renderEnvDict(environment);
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -190,6 +203,7 @@ export function buildLaunchAgentPlist({
<dict>
<key>Label</key>
<string>${plistEscape(label)}</string>
${commentXml}
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
@@ -271,9 +285,9 @@ export function parseLaunchctlPrint(output: string): LaunchctlPrintInfo {
return info;
}
export async function isLaunchAgentLoaded(): Promise<boolean> {
export async function isLaunchAgentLoaded(profile?: string): Promise<boolean> {
const domain = resolveGuiDomain();
const label = GATEWAY_LAUNCH_AGENT_LABEL;
const label = resolveGatewayLaunchAgentLabel(profile);
const res = await execLaunchctl(["print", `${domain}/${label}`]);
return res.code === 0;
}
@@ -294,7 +308,9 @@ export async function readLaunchAgentRuntime(
env: Record<string, string | undefined>,
): Promise<GatewayServiceRuntime> {
const domain = resolveGuiDomain();
const label = GATEWAY_LAUNCH_AGENT_LABEL;
const label =
env.CLAWDBOT_LAUNCHD_LABEL?.trim() ||
resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
const res = await execLaunchctl(["print", `${domain}/${label}`]);
if (res.code !== 0) {
return {
@@ -418,7 +434,10 @@ export async function uninstallLaunchAgent({
const home = resolveHomeDir(env);
const trashDir = path.join(home, ".Trash");
const dest = path.join(trashDir, `${GATEWAY_LAUNCH_AGENT_LABEL}.plist`);
const label =
env.CLAWDBOT_LAUNCHD_LABEL?.trim() ||
resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
const dest = path.join(trashDir, `${label}.plist`);
try {
await fs.mkdir(trashDir, { recursive: true });
await fs.rename(plistPath, dest);
@@ -443,11 +462,13 @@ function isLaunchctlNotLoaded(res: {
export async function stopLaunchAgent({
stdout,
profile,
}: {
stdout: NodeJS.WritableStream;
profile?: string;
}): Promise<void> {
const domain = resolveGuiDomain();
const label = GATEWAY_LAUNCH_AGENT_LABEL;
const label = resolveGatewayLaunchAgentLabel(profile);
const res = await execLaunchctl(["bootout", `${domain}/${label}`]);
if (res.code !== 0 && !isLaunchctlNotLoaded(res)) {
throw new Error(
@@ -474,6 +495,9 @@ export async function installLaunchAgent({
await fs.mkdir(logDir, { recursive: true });
const domain = resolveGuiDomain();
const label =
env.CLAWDBOT_LAUNCHD_LABEL?.trim() ||
resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
for (const legacyLabel of LEGACY_GATEWAY_LAUNCH_AGENT_LABELS) {
const legacyPlistPath = resolveLaunchAgentPlistPathForLabel(
env,
@@ -488,10 +512,17 @@ export async function installLaunchAgent({
}
}
const plistPath = resolveLaunchAgentPlistPath(env);
const plistPath = resolveLaunchAgentPlistPathForLabel(env, label);
await fs.mkdir(path.dirname(plistPath), { recursive: true });
const description = formatGatewayServiceDescription({
profile: env.CLAWDBOT_PROFILE,
version:
environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION,
});
const plist = buildLaunchAgentPlist({
label,
comment: description,
programArguments,
workingDirectory,
stdoutPath,
@@ -508,12 +539,8 @@ export async function installLaunchAgent({
`launchctl bootstrap failed: ${boot.stderr || boot.stdout}`.trim(),
);
}
await execLaunchctl(["enable", `${domain}/${GATEWAY_LAUNCH_AGENT_LABEL}`]);
await execLaunchctl([
"kickstart",
"-k",
`${domain}/${GATEWAY_LAUNCH_AGENT_LABEL}`,
]);
await execLaunchctl(["enable", `${domain}/${label}`]);
await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
stdout.write(`${formatLine("Installed LaunchAgent", plistPath)}\n`);
stdout.write(`${formatLine("Logs", stdoutPath)}\n`);
@@ -522,11 +549,13 @@ export async function installLaunchAgent({
export async function restartLaunchAgent({
stdout,
profile,
}: {
stdout: NodeJS.WritableStream;
profile?: string;
}): Promise<void> {
const domain = resolveGuiDomain();
const label = GATEWAY_LAUNCH_AGENT_LABEL;
const label = resolveGatewayLaunchAgentLabel(profile);
const res = await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
if (res.code !== 0) {
throw new Error(

View File

@@ -5,8 +5,9 @@ import { promisify } from "node:util";
import { colorize, isRich, theme } from "../terminal/theme.js";
import {
GATEWAY_WINDOWS_TASK_NAME,
LEGACY_GATEWAY_WINDOWS_TASK_NAMES,
formatGatewayServiceDescription,
resolveGatewayWindowsTaskName,
} from "./constants.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
@@ -28,7 +29,10 @@ function resolveTaskScriptPath(
env: Record<string, string | undefined>,
): string {
const home = resolveHomeDir(env);
return path.join(home, ".clawdbot", "gateway.cmd");
const profile = env.CLAWDBOT_PROFILE?.trim();
const suffix =
profile && profile.toLowerCase() !== "default" ? `-${profile}` : "";
return path.join(home, `.clawdbot${suffix}`, "gateway.cmd");
}
function resolveLegacyTaskScriptPath(
@@ -78,18 +82,32 @@ function parseCommandLine(value: string): string[] {
export async function readScheduledTaskCommand(
env: Record<string, string | undefined>,
): Promise<{ programArguments: string[]; workingDirectory?: string } | null> {
): Promise<{
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string>;
} | null> {
const scriptPath = resolveTaskScriptPath(env);
try {
const content = await fs.readFile(scriptPath, "utf8");
let workingDirectory = "";
let commandLine = "";
const environment: Record<string, string> = {};
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
if (line.startsWith("@echo")) continue;
if (line.toLowerCase().startsWith("rem ")) continue;
if (line.toLowerCase().startsWith("set ")) continue;
if (line.toLowerCase().startsWith("set ")) {
const assignment = line.slice(4).trim();
const index = assignment.indexOf("=");
if (index > 0) {
const key = assignment.slice(0, index).trim();
const value = assignment.slice(index + 1).trim();
if (key) environment[key] = value;
}
continue;
}
if (line.toLowerCase().startsWith("cd /d ")) {
workingDirectory = line
.slice("cd /d ".length)
@@ -104,6 +122,7 @@ export async function readScheduledTaskCommand(
return {
programArguments: parseCommandLine(commandLine),
...(workingDirectory ? { workingDirectory } : {}),
...(Object.keys(environment).length > 0 ? { environment } : {}),
};
} catch {
return null;
@@ -129,15 +148,20 @@ export function parseSchtasksQuery(output: string): ScheduledTaskInfo {
}
function buildTaskScript({
description,
programArguments,
workingDirectory,
environment,
}: {
description?: string;
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string | undefined>;
}): string {
const lines: string[] = ["@echo off"];
if (description?.trim()) {
lines.push(`rem ${description.trim()}`);
}
if (workingDirectory) {
lines.push(`cd /d ${quoteCmdArg(workingDirectory)}`);
}
@@ -208,13 +232,20 @@ export async function installScheduledTask({
await assertSchtasksAvailable();
const scriptPath = resolveTaskScriptPath(env);
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
const description = formatGatewayServiceDescription({
profile: env.CLAWDBOT_PROFILE,
version:
environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION,
});
const script = buildTaskScript({
description,
programArguments,
workingDirectory,
environment,
});
await fs.writeFile(scriptPath, script, "utf8");
const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
const quotedScript = quoteCmdArg(scriptPath);
const create = await execSchtasks([
"/Create",
@@ -224,7 +255,7 @@ export async function installScheduledTask({
"/RL",
"LIMITED",
"/TN",
GATEWAY_WINDOWS_TASK_NAME,
taskName,
"/TR",
quotedScript,
]);
@@ -234,10 +265,8 @@ export async function installScheduledTask({
);
}
await execSchtasks(["/Run", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
stdout.write(
`${formatLine("Installed Scheduled Task", GATEWAY_WINDOWS_TASK_NAME)}\n`,
);
await execSchtasks(["/Run", "/TN", taskName]);
stdout.write(`${formatLine("Installed Scheduled Task", taskName)}\n`);
stdout.write(`${formatLine("Task script", scriptPath)}\n`);
return { scriptPath };
}
@@ -250,7 +279,8 @@ export async function uninstallScheduledTask({
stdout: NodeJS.WritableStream;
}): Promise<void> {
await assertSchtasksAvailable();
await execSchtasks(["/Delete", "/F", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
await execSchtasks(["/Delete", "/F", "/TN", taskName]);
const scriptPath = resolveTaskScriptPath(env);
try {
@@ -272,42 +302,52 @@ function isTaskNotRunning(res: {
export async function stopScheduledTask({
stdout,
profile,
}: {
stdout: NodeJS.WritableStream;
profile?: string;
}): Promise<void> {
await assertSchtasksAvailable();
const res = await execSchtasks(["/End", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
const taskName = resolveGatewayWindowsTaskName(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());
}
stdout.write(
`${formatLine("Stopped Scheduled Task", GATEWAY_WINDOWS_TASK_NAME)}\n`,
);
stdout.write(`${formatLine("Stopped Scheduled Task", taskName)}\n`);
}
export async function restartScheduledTask({
stdout,
profile,
}: {
stdout: NodeJS.WritableStream;
profile?: string;
}): Promise<void> {
await assertSchtasksAvailable();
await execSchtasks(["/End", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
const res = await execSchtasks(["/Run", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
const taskName = resolveGatewayWindowsTaskName(profile);
await execSchtasks(["/End", "/TN", taskName]);
const res = await execSchtasks(["/Run", "/TN", taskName]);
if (res.code !== 0) {
throw new Error(`schtasks run failed: ${res.stderr || res.stdout}`.trim());
}
stdout.write(
`${formatLine("Restarted Scheduled Task", GATEWAY_WINDOWS_TASK_NAME)}\n`,
);
stdout.write(`${formatLine("Restarted Scheduled Task", taskName)}\n`);
}
export async function isScheduledTaskInstalled(): Promise<boolean> {
export async function isScheduledTaskInstalled(
profile?: string,
): Promise<boolean> {
await assertSchtasksAvailable();
const res = await execSchtasks(["/Query", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
const taskName = resolveGatewayWindowsTaskName(profile);
const res = await execSchtasks(["/Query", "/TN", taskName]);
return res.code === 0;
}
export async function readScheduledTaskRuntime(): Promise<GatewayServiceRuntime> {
export async function readScheduledTaskRuntime(
env: Record<string, string | undefined> = process.env as Record<
string,
string | undefined
>,
): Promise<GatewayServiceRuntime> {
try {
await assertSchtasksAvailable();
} catch (err) {
@@ -316,10 +356,11 @@ export async function readScheduledTaskRuntime(): Promise<GatewayServiceRuntime>
detail: String(err),
};
}
const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
const res = await execSchtasks([
"/Query",
"/TN",
GATEWAY_WINDOWS_TASK_NAME,
taskName,
"/V",
"/FO",
"LIST",

View File

@@ -58,5 +58,23 @@ describe("buildServiceEnvironment", () => {
}
expect(env.CLAWDBOT_GATEWAY_PORT).toBe("18789");
expect(env.CLAWDBOT_GATEWAY_TOKEN).toBe("secret");
expect(env.CLAWDBOT_SERVICE_MARKER).toBe("clawdbot");
expect(env.CLAWDBOT_SERVICE_KIND).toBe("gateway");
expect(typeof env.CLAWDBOT_SERVICE_VERSION).toBe("string");
expect(env.CLAWDBOT_SYSTEMD_UNIT).toBe("clawdbot-gateway.service");
if (process.platform === "darwin") {
expect(env.CLAWDBOT_LAUNCHD_LABEL).toBe("com.clawdbot.gateway");
}
});
it("uses profile-specific unit and label", () => {
const env = buildServiceEnvironment({
env: { HOME: "/home/user", CLAWDBOT_PROFILE: "work" },
port: 18789,
});
expect(env.CLAWDBOT_SYSTEMD_UNIT).toBe("clawdbot-gateway-work.service");
if (process.platform === "darwin") {
expect(env.CLAWDBOT_LAUNCHD_LABEL).toBe("com.clawdbot.work");
}
});
});

View File

@@ -1,5 +1,13 @@
import path from "node:path";
import { VERSION } from "../version.js";
import {
GATEWAY_SERVICE_KIND,
GATEWAY_SERVICE_MARKER,
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
} from "./constants.js";
export type MinimalServicePathOptions = {
platform?: NodeJS.Platform;
extraDirs?: string[];
@@ -59,13 +67,24 @@ export function buildServiceEnvironment(params: {
launchdLabel?: string;
}): Record<string, string | undefined> {
const { env, port, token, launchdLabel } = params;
const profile = env.CLAWDBOT_PROFILE;
const resolvedLaunchdLabel =
launchdLabel ||
(process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(profile)
: undefined);
const systemdUnit = `${resolveGatewaySystemdServiceName(profile)}.service`;
return {
PATH: buildMinimalServicePath({ env }),
CLAWDBOT_PROFILE: env.CLAWDBOT_PROFILE,
CLAWDBOT_PROFILE: profile,
CLAWDBOT_STATE_DIR: env.CLAWDBOT_STATE_DIR,
CLAWDBOT_CONFIG_PATH: env.CLAWDBOT_CONFIG_PATH,
CLAWDBOT_GATEWAY_PORT: String(port),
CLAWDBOT_GATEWAY_TOKEN: token,
CLAWDBOT_LAUNCHD_LABEL: launchdLabel,
CLAWDBOT_LAUNCHD_LABEL: resolvedLaunchdLabel,
CLAWDBOT_SYSTEMD_UNIT: systemdUnit,
CLAWDBOT_SERVICE_MARKER: GATEWAY_SERVICE_MARKER,
CLAWDBOT_SERVICE_KIND: GATEWAY_SERVICE_KIND,
CLAWDBOT_SERVICE_VERSION: VERSION,
};
}

View File

@@ -44,11 +44,15 @@ export type GatewayService = {
env: Record<string, string | undefined>;
stdout: NodeJS.WritableStream;
}) => Promise<void>;
stop: (args: { stdout: NodeJS.WritableStream }) => Promise<void>;
restart: (args: { stdout: NodeJS.WritableStream }) => Promise<void>;
isLoaded: (args: {
env: Record<string, string | undefined>;
}) => Promise<boolean>;
stop: (args: {
profile?: string;
stdout: NodeJS.WritableStream;
}) => Promise<void>;
restart: (args: {
profile?: string;
stdout: NodeJS.WritableStream;
}) => Promise<void>;
isLoaded: (args: { profile?: string }) => Promise<boolean>;
readCommand: (env: Record<string, string | undefined>) => Promise<{
programArguments: string[];
workingDirectory?: string;
@@ -73,12 +77,15 @@ export function resolveGatewayService(): GatewayService {
await uninstallLaunchAgent(args);
},
stop: async (args) => {
await stopLaunchAgent(args);
await stopLaunchAgent({ stdout: args.stdout, profile: args.profile });
},
restart: async (args) => {
await restartLaunchAgent(args);
await restartLaunchAgent({
stdout: args.stdout,
profile: args.profile,
});
},
isLoaded: async () => isLaunchAgentLoaded(),
isLoaded: async (args) => isLaunchAgentLoaded(args.profile),
readCommand: readLaunchAgentProgramArguments,
readRuntime: readLaunchAgentRuntime,
};
@@ -96,14 +103,17 @@ export function resolveGatewayService(): GatewayService {
await uninstallSystemdService(args);
},
stop: async (args) => {
await stopSystemdService(args);
await stopSystemdService({ stdout: args.stdout, profile: args.profile });
},
restart: async (args) => {
await restartSystemdService(args);
await restartSystemdService({
stdout: args.stdout,
profile: args.profile,
});
},
isLoaded: async () => isSystemdServiceEnabled(),
isLoaded: async (args) => isSystemdServiceEnabled(args.profile),
readCommand: readSystemdServiceExecStart,
readRuntime: async () => await readSystemdServiceRuntime(),
readRuntime: async (env) => await readSystemdServiceRuntime(env),
};
}
@@ -119,14 +129,17 @@ export function resolveGatewayService(): GatewayService {
await uninstallScheduledTask(args);
},
stop: async (args) => {
await stopScheduledTask(args);
await stopScheduledTask({ stdout: args.stdout, profile: args.profile });
},
restart: async (args) => {
await restartScheduledTask(args);
await restartScheduledTask({
stdout: args.stdout,
profile: args.profile,
});
},
isLoaded: async () => isScheduledTaskInstalled(),
isLoaded: async (args) => isScheduledTaskInstalled(args.profile),
readCommand: readScheduledTaskCommand,
readRuntime: async () => await readScheduledTaskRuntime(),
readRuntime: async (env) => await readScheduledTaskRuntime(env),
};
}

View File

@@ -6,8 +6,9 @@ import { promisify } from "node:util";
import { runCommandWithTimeout, runExec } from "../process/exec.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import {
GATEWAY_SYSTEMD_SERVICE_NAME,
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
formatGatewayServiceDescription,
resolveGatewaySystemdServiceName,
} from "./constants.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
@@ -33,10 +34,22 @@ function resolveSystemdUnitPathForName(
return path.join(home, ".config", "systemd", "user", `${name}.service`);
}
function resolveSystemdServiceName(
env: Record<string, string | undefined>,
): string {
const override = env.CLAWDBOT_SYSTEMD_UNIT?.trim();
if (override) {
return override.endsWith(".service")
? override.slice(0, -".service".length)
: override;
}
return resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
}
function resolveSystemdUnitPath(
env: Record<string, string | undefined>,
): string {
return resolveSystemdUnitPathForName(env, GATEWAY_SYSTEMD_SERVICE_NAME);
return resolveSystemdUnitPathForName(env, resolveSystemdServiceName(env));
}
export function resolveSystemdUserUnitPath(
@@ -137,22 +150,25 @@ function renderEnvLines(
}
function buildSystemdUnit({
description,
programArguments,
workingDirectory,
environment,
}: {
description?: string;
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string | undefined>;
}): string {
const execStart = programArguments.map(systemdEscapeArg).join(" ");
const descriptionLine = `Description=${description?.trim() || "Clawdbot Gateway"}`;
const workingDirLine = workingDirectory
? `WorkingDirectory=${systemdEscapeArg(workingDirectory)}`
: null;
const envLines = renderEnvLines(environment);
return [
"[Unit]",
"Description=Clawdbot Gateway",
descriptionLine,
"After=network-online.target",
"Wants=network-online.target",
"",
@@ -387,14 +403,21 @@ export async function installSystemdService({
const unitPath = resolveSystemdUnitPath(env);
await fs.mkdir(path.dirname(unitPath), { recursive: true });
const description = formatGatewayServiceDescription({
profile: env.CLAWDBOT_PROFILE,
version:
environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION,
});
const unit = buildSystemdUnit({
description,
programArguments,
workingDirectory,
environment,
});
await fs.writeFile(unitPath, unit, "utf8");
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
const serviceName = resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
const unitName = `${serviceName}.service`;
const reload = await execSystemctl(["--user", "daemon-reload"]);
if (reload.code !== 0) {
throw new Error(
@@ -428,7 +451,8 @@ export async function uninstallSystemdService({
stdout: NodeJS.WritableStream;
}): Promise<void> {
await assertSystemdAvailable();
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
const serviceName = resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
const unitName = `${serviceName}.service`;
await execSystemctl(["--user", "disable", "--now", unitName]);
const unitPath = resolveSystemdUnitPath(env);
@@ -442,11 +466,14 @@ export async function uninstallSystemdService({
export async function stopSystemdService({
stdout,
profile,
}: {
stdout: NodeJS.WritableStream;
profile?: string;
}): Promise<void> {
await assertSystemdAvailable();
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
const serviceName = resolveGatewaySystemdServiceName(profile);
const unitName = `${serviceName}.service`;
const res = await execSystemctl(["--user", "stop", unitName]);
if (res.code !== 0) {
throw new Error(
@@ -458,11 +485,14 @@ export async function stopSystemdService({
export async function restartSystemdService({
stdout,
profile,
}: {
stdout: NodeJS.WritableStream;
profile?: string;
}): Promise<void> {
await assertSystemdAvailable();
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
const serviceName = resolveGatewaySystemdServiceName(profile);
const unitName = `${serviceName}.service`;
const res = await execSystemctl(["--user", "restart", unitName]);
if (res.code !== 0) {
throw new Error(
@@ -472,14 +502,22 @@ export async function restartSystemdService({
stdout.write(`${formatLine("Restarted systemd service", unitName)}\n`);
}
export async function isSystemdServiceEnabled(): Promise<boolean> {
export async function isSystemdServiceEnabled(
profile?: string,
): Promise<boolean> {
await assertSystemdAvailable();
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
const serviceName = resolveGatewaySystemdServiceName(profile);
const unitName = `${serviceName}.service`;
const res = await execSystemctl(["--user", "is-enabled", unitName]);
return res.code === 0;
}
export async function readSystemdServiceRuntime(): Promise<GatewayServiceRuntime> {
export async function readSystemdServiceRuntime(
env: Record<string, string | undefined> = process.env as Record<
string,
string | undefined
>,
): Promise<GatewayServiceRuntime> {
try {
await assertSystemdAvailable();
} catch (err) {
@@ -488,7 +526,8 @@ export async function readSystemdServiceRuntime(): Promise<GatewayServiceRuntime
detail: String(err),
};
}
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
const serviceName = resolveSystemdServiceName(env);
const unitName = `${serviceName}.service`;
const res = await execSystemctl([
"--user",
"show",

View File

@@ -26,7 +26,7 @@ import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import {
buildCommandText,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
shouldHandleTextCommands,
} from "../auto-reply/commands-registry.js";
import {
@@ -416,7 +416,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
throw new Error("Failed to resolve Discord application id");
}
const commandSpecs = nativeEnabled ? listNativeCommandSpecs() : [];
const commandSpecs = nativeEnabled
? listNativeCommandSpecsForConfig(cfg)
: [];
const commands = commandSpecs.map((spec) =>
createDiscordNativeCommand({
command: spec,
@@ -928,7 +930,7 @@ export function createDiscordMessageHandler(params: {
!wasMentioned &&
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(baseText);
hasControlCommand(baseText, cfg);
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
const canDetectMention = Boolean(botId) || mentionRegexes.length > 0;
if (isGuildMessage && shouldRequireMention) {

View File

@@ -1,7 +1,7 @@
import { spawnSync } from "node:child_process";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
GATEWAY_SYSTEMD_SERVICE_NAME,
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
} from "../daemon/constants.js";
export type RestartAttempt = {
@@ -41,9 +41,11 @@ function formatSpawnDetail(result: {
return "unknown error";
}
function normalizeSystemdUnit(raw?: string): string {
function normalizeSystemdUnit(raw?: string, profile?: string): string {
const unit = raw?.trim();
if (!unit) return `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
if (!unit) {
return `${resolveGatewaySystemdServiceName(profile)}.service`;
}
return unit.endsWith(".service") ? unit : `${unit}.service`;
}
@@ -54,7 +56,10 @@ export function triggerClawdbotRestart(): RestartAttempt {
const tried: string[] = [];
if (process.platform !== "darwin") {
if (process.platform === "linux") {
const unit = normalizeSystemdUnit(process.env.CLAWDBOT_SYSTEMD_UNIT);
const unit = normalizeSystemdUnit(
process.env.CLAWDBOT_SYSTEMD_UNIT,
process.env.CLAWDBOT_PROFILE,
);
const userArgs = ["--user", "restart", unit];
tried.push(`systemctl ${userArgs.join(" ")}`);
const userRestart = spawnSync("systemctl", userArgs, {
@@ -87,7 +92,8 @@ export function triggerClawdbotRestart(): RestartAttempt {
}
const label =
process.env.CLAWDBOT_LAUNCHD_LABEL || GATEWAY_LAUNCH_AGENT_LABEL;
process.env.CLAWDBOT_LAUNCHD_LABEL ||
resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE);
const uid =
typeof process.getuid === "function" ? process.getuid() : undefined;
const target = uid !== undefined ? `gui/${uid}/${label}` : label;

View File

@@ -16,7 +16,7 @@ import {
import { hasControlCommand } from "../auto-reply/command-detection.js";
import {
buildCommandText,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
shouldHandleTextCommands,
} from "../auto-reply/commands-registry.js";
import {
@@ -891,7 +891,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
!wasMentioned &&
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(message.text ?? "");
hasControlCommand(message.text ?? "", cfg);
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
const canDetectMention = Boolean(botUserId) || mentionRegexes.length > 0;
if (
@@ -1945,7 +1945,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
};
const nativeCommands =
cfg.commands?.native === true ? listNativeCommandSpecs() : [];
cfg.commands?.native === true ? listNativeCommandSpecsForConfig(cfg) : [];
if (nativeCommands.length > 0) {
for (const command of nativeCommands) {
app.command(

View File

@@ -16,7 +16,7 @@ import {
import { hasControlCommand } from "../auto-reply/command-detection.js";
import {
buildCommandText,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
} from "../auto-reply/commands-registry.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { resolveTelegramDraftStreamingChunking } from "../auto-reply/reply/block-streaming.js";
@@ -557,7 +557,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
!wasMentioned &&
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(msg.text ?? msg.caption ?? "");
hasControlCommand(msg.text ?? msg.caption ?? "", cfg);
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
if (isGroup && requireMention && canDetectMention) {
@@ -907,7 +907,9 @@ export function createTelegramBot(opts: TelegramBotOptions) {
}
};
const nativeCommands = nativeEnabled ? listNativeCommandSpecs() : [];
const nativeCommands = nativeEnabled
? listNativeCommandSpecsForConfig(cfg)
: [];
if (nativeCommands.length > 0) {
bot.api
.setMyCommands(

View File

@@ -52,7 +52,7 @@ import {
resolveGatewayPort,
writeConfigFile,
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
@@ -627,7 +627,9 @@ export async function runOnboardingWizard(
);
}
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
const loaded = await service.isLoaded({
profile: process.env.CLAWDBOT_PROFILE,
});
if (loaded) {
const action = (await prompter.select({
message: "Gateway service already installed",
@@ -638,7 +640,10 @@ export async function runOnboardingWizard(
],
})) as "restart" | "reinstall" | "skip";
if (action === "restart") {
await service.restart({ stdout: process.stdout });
await service.restart({
profile: process.env.CLAWDBOT_PROFILE,
stdout: process.stdout,
});
} else if (action === "reinstall") {
await service.uninstall({ env: process.env, stdout: process.stdout });
}
@@ -646,7 +651,9 @@ export async function runOnboardingWizard(
if (
!loaded ||
(loaded && (await service.isLoaded({ env: process.env })) === false)
(loaded &&
(await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE })) ===
false)
) {
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
@@ -668,7 +675,7 @@ export async function runOnboardingWizard(
token: gatewayToken,
launchdLabel:
process.platform === "darwin"
? GATEWAY_LAUNCH_AGENT_LABEL
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
: undefined,
});
await service.install({