diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 094a39ce0..e04c065d7 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -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 diff --git a/docs/cli/index.md b/docs/cli/index.md index cd499b273..2d25cb1da 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -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 daemon’s 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`. diff --git a/docs/debugging.md b/docs/debugging.md index e9dd74d99..992f49421 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -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: diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 165ef74cb..cb4133359 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -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. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 22ec0ea31..50d2822ab 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -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 diff --git a/docs/gateway/index.md b/docs/gateway/index.md index 9643e9692..e46c1fd44 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -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.` +- Linux: `clawdbot-gateway-.service` +- Windows: `Clawdbot Gateway ()` + +Install metadata is embedded in the service config: +- `CLAWDBOT_SERVICE_MARKER=clawdbot` +- `CLAWDBOT_SERVICE_KIND=gateway` +- `CLAWDBOT_SERVICE_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..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.`). - 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.` 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[-].service`: ``` [Unit] -Description=Clawdbot Gateway +Description=Clawdbot Gateway (profile: , v) 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[-].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[-].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[-].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. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index b3fd0791f..104cc645d 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -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[-].service -n 200 --no-pager` +- Windows: `schtasks /Query /TN "Clawdbot Gateway ()" /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. 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-/logs/...`)
Linux: `journalctl --user -u clawdbot-gateway.service -n 200 --no-pager`
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-/logs/...`)
Linux: `journalctl --user -u clawdbot-gateway[-].service -n 200 --no-pager`
Windows: `schtasks /Query /TN "Clawdbot Gateway ()" /V /FO LIST` | | Session files | `$CLAWDBOT_STATE_DIR/agents//sessions/` | | Media cache | `$CLAWDBOT_STATE_DIR/media/` | | Credentials | `$CLAWDBOT_STATE_DIR/credentials/` | diff --git a/docs/install/updating.md b/docs/install/updating.md index f0c8a6b6b..70221f8ed 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -124,9 +124,9 @@ clawdbot logs --follow ``` If you’re 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.` if set) +- Linux systemd user service: `systemctl --user restart clawdbot-gateway[-].service` +- Windows (WSL2): `systemctl --user restart clawdbot-gateway[-].service` - `launchctl`/`systemctl` only work if the service is installed; otherwise run `clawdbot daemon install`. Runbook + exact service labels: [Gateway runbook](/gateway) diff --git a/docs/platforms/exe-dev.md b/docs/platforms/exe-dev.md index b07514eb7..fb3d6d036 100644 --- a/docs/platforms/exe-dev.md +++ b/docs/platforms/exe-dev.md @@ -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[-].service ``` If the service dies after logout, enable lingering: diff --git a/docs/platforms/index.md b/docs/platforms/index.md index 80c630cd4..15dd9ffd7 100644 --- a/docs/platforms/index.md +++ b/docs/platforms/index.md @@ -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.`) +- Linux/WSL2: systemd user service (`clawdbot-gateway[-].service`) diff --git a/docs/platforms/linux.md b/docs/platforms/linux.md index c3c4ebe06..1f66144a4 100644 --- a/docs/platforms/linux.md +++ b/docs/platforms/linux.md @@ -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[-].service`: ``` [Unit] -Description=Clawdbot Gateway +Description=Clawdbot Gateway (profile: , v) 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[-].service ``` diff --git a/docs/platforms/mac/bundled-gateway.md b/docs/platforms/mac/bundled-gateway.md index 6ddaf96c5..7a8331653 100644 --- a/docs/platforms/mac/bundled-gateway.md +++ b/docs/platforms/mac/bundled-gateway.md @@ -63,10 +63,10 @@ Version injection: ## Launchd (Gateway as LaunchAgent) Label: -- `com.clawdbot.gateway` +- `com.clawdbot.gateway` (or `com.clawdbot.`) Plist location (per-user): -- `~/Library/LaunchAgents/com.clawdbot.gateway.plist` +- `~/Library/LaunchAgents/com.clawdbot.gateway.plist` (or `.../com.clawdbot..plist`) Manager: - The macOS app owns LaunchAgent install/update for the bundled gateway. diff --git a/docs/platforms/mac/child-process.md b/docs/platforms/mac/child-process.md index 12836f8c9..142147c53 100644 --- a/docs/platforms/mac/child-process.md +++ b/docs/platforms/mac/child-process.md @@ -14,7 +14,8 @@ manually in a terminal. ## Default behavior (launchd) -- The app installs a per‑user LaunchAgent labeled `com.clawdbot.gateway`. +- The app installs a per‑user LaunchAgent labeled `com.clawdbot.gateway` + (or `com.clawdbot.` 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.` when running a named profile. + ## Attach‑only (developer mode) Attach‑only tells the app to **connect to an existing Gateway** without spawning diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index 1575c3f49..37499bb5a 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -31,13 +31,16 @@ node. ## Launchd control -The app manages a per‑user LaunchAgent labeled `com.clawdbot.gateway`. +The app manages a per‑user LaunchAgent labeled `com.clawdbot.gateway` +(or `com.clawdbot.` 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.` when running a named profile. + If the LaunchAgent isn’t installed, enable it from the app or run `clawdbot daemon install`. diff --git a/docs/start/faq.md b/docs/start/faq.md index acf57a275..82a679689 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -606,6 +606,8 @@ Yes, but you must isolate: - `gateway.port` (unique ports) There are convenience CLI flags like `--dev` and `--profile ` that shift state dirs and ports. +When using profiles, service names are suffixed (`com.clawdbot.`, `clawdbot-gateway-.service`, +`Clawdbot Gateway ()`). ## 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-/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[-].service -n 200 --no-pager` +- Windows: `schtasks /Query /TN "Clawdbot Gateway ()" /V /FO LIST` See [Troubleshooting](/gateway/troubleshooting#log-locations) for more. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 9c4ad3603..986b4743a 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -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: diff --git a/src/auto-reply/command-detection.test.ts b/src/auto-reply/command-detection.test.ts index fc3c22973..e7dcba238 100644 --- a/src/auto-reply/command-detection.test.ts +++ b/src/auto-reply/command-detection.test.ts @@ -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); diff --git a/src/auto-reply/command-detection.ts b/src/auto-reply/command-detection.ts index e9345a5d9..d96da65c4 100644 --- a/src/auto-reply/command-detection.ts +++ b/src/auto-reply/command-detection.ts @@ -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; diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index ea5f18a31..8860a02a7 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -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); diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 10c1e8367..3cfd10cb7 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -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 { diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index b5d53cf77..423c638e9 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -877,7 +877,7 @@ export async function getReplyFromConfig( allowTextCommands && !commandAuthorized && !baseBodyTrimmedRaw && - hasControlCommand(commandSource) + hasControlCommand(commandSource, cfg) ) { typing.cleanup(); return undefined; diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 49d8bd589..cd93458fe 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -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, diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 98ebd6d2b..9273e187f 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -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"); }); }); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index c4018bf61..ae824ac7b 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -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 ", + "/verbose on|off", + "/reasoning on|off", + "/elevated on|off", + "/model ", + "/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 | /verbose on|off | /reasoning on|off | /elevated on|off | /model | /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}`; diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index 793b1ea5b..6e09a6a24 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -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 { 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 { 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 { 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)}`); diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 9ec5860a2..ab93c80ef 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -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; } diff --git a/src/commands/configure.ts b/src/commands/configure.ts index a41193d86..cab220c46 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -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, diff --git a/src/commands/doctor-format.ts b/src/commands/doctor-format.ts index ecadd104d..0fb470038 100644 --- a/src/commands/doctor-format.ts +++ b/src/commands/doctor-format.ts @@ -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; diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index f17ee63da..70535c998 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -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 { diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index de895de9a..460691ba1 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -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> | 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); diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index a600edd6a..da379a14d 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -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, diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index 1a2da354e..c86272743 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -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), ]); diff --git a/src/commands/status.ts b/src/commands/status.ts index bc8d9df0e..79a0a046e 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -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), ]); diff --git a/src/config/config-paths.test.ts b/src/config/config-paths.test.ts index 9effad799..fafa06d04 100644 --- a/src/config/config-paths.test.ts +++ b/src/config/config-paths.test.ts @@ -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", () => { diff --git a/src/config/runtime-overrides.test.ts b/src/config/runtime-overrides.test.ts index d7630d865..5f5758d10 100644 --- a/src/config/runtime-overrides.test.ts +++ b/src/config/runtime-overrides.test.ts @@ -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); + } + }); }); diff --git a/src/config/schema.ts b/src/config/schema.ts index 889a54d86..2d4052ea9 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -115,6 +115,8 @@ const FIELD_LABELS: Record = { "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 = { "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": diff --git a/src/config/types.ts b/src/config/types.ts index 1b95e7ff7..b64101939 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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). */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 71cf2f404..8cec1c4de 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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(), }) diff --git a/src/daemon/constants.test.ts b/src/daemon/constants.test.ts new file mode 100644 index 000000000..20476c405 --- /dev/null +++ b/src/daemon/constants.test.ts @@ -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)"); + }); +}); diff --git a/src/daemon/constants.ts b/src/daemon/constants.ts index 3c09451b9..a33cb7cfd 100644 --- a/src/daemon/constants.ts +++ b/src/daemon/constants.ts @@ -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(", ")})`; +} diff --git a/src/daemon/inspect.ts b/src/daemon/inspect.ts index 3f46ac717..82e759457 100644 --- a/src/daemon/inspect.ts +++ b/src/daemon/inspect.ts @@ -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 = 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( /Label<\/key>\s*([\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() ?? ""; diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index f20b9ca88..11827c76e 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -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 { - 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({ WorkingDirectory ${plistEscape(workingDirectory)}` : ""; + const commentXml = + comment && comment.trim() + ? ` + Comment + ${plistEscape(comment.trim())}` + : ""; const envXml = renderEnvDict(environment); return ` @@ -190,6 +203,7 @@ export function buildLaunchAgentPlist({ Label ${plistEscape(label)} + ${commentXml} RunAtLoad KeepAlive @@ -271,9 +285,9 @@ export function parseLaunchctlPrint(output: string): LaunchctlPrintInfo { return info; } -export async function isLaunchAgentLoaded(): Promise { +export async function isLaunchAgentLoaded(profile?: string): Promise { 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, ): Promise { 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 { 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 { 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( diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 565fed532..65b2b7617 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -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 { 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, -): Promise<{ programArguments: string[]; workingDirectory?: string } | null> { +): Promise<{ + programArguments: string[]; + workingDirectory?: string; + environment?: Record; +} | null> { const scriptPath = resolveTaskScriptPath(env); try { const content = await fs.readFile(scriptPath, "utf8"); let workingDirectory = ""; let commandLine = ""; + const environment: Record = {}; 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 { 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 { 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 { 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 { 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 { +export async function isScheduledTaskInstalled( + profile?: string, +): Promise { 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 { +export async function readScheduledTaskRuntime( + env: Record = process.env as Record< + string, + string | undefined + >, +): Promise { try { await assertSchtasksAvailable(); } catch (err) { @@ -316,10 +356,11 @@ export async function readScheduledTaskRuntime(): Promise detail: String(err), }; } + const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE); const res = await execSchtasks([ "/Query", "/TN", - GATEWAY_WINDOWS_TASK_NAME, + taskName, "/V", "/FO", "LIST", diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index f43b25091..d9476bac1 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -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"); + } }); }); diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index 51f60bd61..58aeda27f 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -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 { 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, }; } diff --git a/src/daemon/service.ts b/src/daemon/service.ts index a7f45f6c1..973474c15 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -44,11 +44,15 @@ export type GatewayService = { env: Record; stdout: NodeJS.WritableStream; }) => Promise; - stop: (args: { stdout: NodeJS.WritableStream }) => Promise; - restart: (args: { stdout: NodeJS.WritableStream }) => Promise; - isLoaded: (args: { - env: Record; - }) => Promise; + stop: (args: { + profile?: string; + stdout: NodeJS.WritableStream; + }) => Promise; + restart: (args: { + profile?: string; + stdout: NodeJS.WritableStream; + }) => Promise; + isLoaded: (args: { profile?: string }) => Promise; readCommand: (env: Record) => 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), }; } diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index b39cd30cc..03d5afd9c 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -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 { + 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 { - 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 { 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 { 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 { 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 { 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 { +export async function isSystemdServiceEnabled( + profile?: string, +): Promise { 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 { +export async function readSystemdServiceRuntime( + env: Record = process.env as Record< + string, + string | undefined + >, +): Promise { try { await assertSystemdAvailable(); } catch (err) { @@ -488,7 +526,8 @@ export async function readSystemdServiceRuntime(): Promise 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) { diff --git a/src/infra/restart.ts b/src/infra/restart.ts index 4b817e924..2fb2eba86 100644 --- a/src/infra/restart.ts +++ b/src/infra/restart.ts @@ -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; diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index 072b233c0..af8101a71 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -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( diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index dfaf49ea0..f56aa8e77 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -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( diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 6a1d7d5f3..6404bc9bd 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -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({