feat: improve gateway services and auto-reply commands

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

View File

@ -77,7 +77,7 @@ clawdbot gateway health --url ws://127.0.0.1:18789
- your configured remote gateway (if set), and - your configured remote gateway (if set), and
- localhost (loopback) **even if remote is configured**. - 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 ```bash
clawdbot gateway status clawdbot gateway status

View File

@ -156,7 +156,7 @@ Chat messages support `/...` commands (text and native). See [/tools/slash-comma
Highlights: Highlights:
- `/status` for quick diagnostics. - `/status` for quick diagnostics.
- `/config` for persisted config changes. - `/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 ## Setup + onboarding
@ -448,7 +448,7 @@ Subcommands:
Notes: Notes:
- `daemon status` probes the Gateway RPC by default using the daemons resolved port/config (override with `--url/--token/--password`). - `daemon status` probes the Gateway RPC by default using the daemons resolved port/config (override with `--url/--token/--password`).
- `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting. - `daemon status` 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 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` defaults to Node runtime; use `--runtime bun` only when WhatsApp is disabled.
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`. - `daemon install` options: `--port`, `--runtime`, `--token`, `--force`.

View File

@ -14,6 +14,7 @@ provider mixes reasoning into normal text.
## Runtime debug overrides ## Runtime debug overrides
Use `/debug` in chat to set **runtime-only** config overrides (memory, not disk). 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`. This is handy when you need to toggle obscure settings without editing `clawdbot.json`.
Examples: Examples:

View File

@ -614,6 +614,8 @@ Controls how chat commands are enabled across connectors.
commands: { commands: {
native: false, // register native commands when supported native: false, // register native commands when supported
text: true, // parse slash commands in chat messages 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 restart: false, // allow /restart + gateway restart tool
useAccessGroups: true // enforce access-group allowlists/policies for commands useAccessGroups: true // enforce access-group allowlists/policies for commands
} }
@ -625,6 +627,8 @@ Notes:
- `commands.text: false` disables parsing chat messages for commands. - `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: 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.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.restart: true` enables `/restart` and the gateway tool restart action.
- `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies. - `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies.

View File

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

View File

@ -51,6 +51,16 @@ pnpm gateway:watch
Supported if you isolate state + config and use unique ports. Supported if you isolate state + config and use unique ports.
Service names are profile-aware:
- macOS: `com.clawdbot.<profile>`
- Linux: `clawdbot-gateway-<profile>.service`
- Windows: `Clawdbot Gateway (<profile>)`
Install metadata is embedded in the service config:
- `CLAWDBOT_SERVICE_MARKER=clawdbot`
- `CLAWDBOT_SERVICE_KIND=gateway`
- `CLAWDBOT_SERVICE_VERSION=<version>`
### Dev profile (`--dev`) ### Dev profile (`--dev`)
Fast path: run a fully-isolated dev instance (config/state/workspace) without touching your primary setup. 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` - StandardOut/Err: file paths or `syslog`
- On failure, launchd restarts; fatal misconfig should keep exiting so the operator notices. - 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). - LaunchAgents are per-user and require a logged-in session; for headless setups use a custom LaunchDaemon (not shipped).
- `clawdbot daemon install` writes `~/Library/LaunchAgents/com.clawdbot.gateway.plist`. - `clawdbot daemon install` writes `~/Library/LaunchAgents/com.clawdbot.gateway.plist`
(or `com.clawdbot.<profile>.plist`).
- `clawdbot doctor` audits the LaunchAgent config and can update it to current defaults. - `clawdbot doctor` audits the LaunchAgent config and can update it to current defaults.
## Daemon management (CLI) ## 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` 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. - `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). - `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). - 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). - `daemon install` is a no-op when already installed; use `clawdbot daemon install --force` to reinstall (profile/env/path changes).
Bundled mac app: Bundled mac app:
- Clawdbot.app can bundle a Node-based gateway relay and install a per-user LaunchAgent labeled `com.clawdbot.gateway`. - Clawdbot.app can bundle a Node-based gateway relay and install a per-user LaunchAgent labeled
`com.clawdbot.gateway` (or `com.clawdbot.<profile>`).
- To stop it cleanly, use `clawdbot daemon stop` (or `launchctl bootout gui/$UID/com.clawdbot.gateway`). - To 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`). - 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. - `launchctl` only works if the LaunchAgent is installed; otherwise use `clawdbot daemon install` first.
- Replace the label with `com.clawdbot.<profile>` when running a named profile.
## Supervision (systemd user unit) ## Supervision (systemd user unit)
Clawdbot installs a **systemd user service** by default on Linux/WSL2. We 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 `clawdbot daemon install` writes the user unit. `clawdbot doctor` audits the
unit and can update it to match the current recommended defaults. unit and can update it to match the current recommended defaults.
Create `~/.config/systemd/user/clawdbot-gateway.service`: Create `~/.config/systemd/user/clawdbot-gateway[-<profile>].service`:
``` ```
[Unit] [Unit]
Description=Clawdbot Gateway Description=Clawdbot Gateway (profile: <profile>, v<version>)
After=network-online.target After=network-online.target
Wants=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`). Onboarding runs this on Linux/WSL2 (may prompt for sudo; writes `/var/lib/systemd/linger`).
Then enable the service: Then enable the service:
``` ```
systemctl --user enable --now clawdbot-gateway.service systemctl --user enable --now clawdbot-gateway[-<profile>].service
``` ```
**Alternative (system service)** - for always-on or multi-user servers, you can **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). install a systemd **system** unit instead of a user unit (no lingering needed).
Create `/etc/systemd/system/clawdbot-gateway.service` (copy the unit above, Create `/etc/systemd/system/clawdbot-gateway[-<profile>].service` (copy the unit above,
switch `WantedBy=multi-user.target`, set `User=` + `WorkingDirectory=`), then: switch `WantedBy=multi-user.target`, set `User=` + `WorkingDirectory=`), then:
``` ```
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now clawdbot-gateway.service sudo systemctl enable --now clawdbot-gateway[-<profile>].service
``` ```
## Windows (WSL2) ## 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. - Debug: subscribe to `tick` and `presence` events; ensure `status` shows linked/auth age; presence entries show Gateway host and connected clients.
## Safety guarantees ## 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. - 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. - 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. - Graceful shutdown: emit `shutdown` event before closing; clients must handle close + reconnect.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,9 @@ import {
buildCommandText, buildCommandText,
getCommandDetection, getCommandDetection,
listChatCommands, listChatCommands,
listChatCommandsForConfig,
listNativeCommandSpecs, listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
shouldHandleTextCommands, shouldHandleTextCommands,
} from "./commands-registry.js"; } from "./commands-registry.js";
@ -21,6 +23,26 @@ describe("commands registry", () => {
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy(); 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", () => { it("detects known text commands", () => {
const detection = getCommandDetection(); const detection = getCommandDetection();
expect(detection.exact.has("/commands")).toBe(true); expect(detection.exact.has("/commands")).toBe(true);

View File

@ -290,6 +290,21 @@ export function listChatCommands(): ChatCommandDefinition[] {
return [...CHAT_COMMANDS]; 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[] { export function listNativeCommandSpecs(): NativeCommandSpec[] {
return CHAT_COMMANDS.filter( return CHAT_COMMANDS.filter(
(command) => command.scope !== "text" && command.nativeName, (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( export function findCommandByNativeName(
name: string, name: string,
): ChatCommandDefinition | undefined { ): ChatCommandDefinition | undefined {

View File

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

View File

@ -602,7 +602,7 @@ export async function handleCommands(params: {
); );
return { shouldContinue: false }; return { shouldContinue: false };
} }
return { shouldContinue: false, reply: { text: buildHelpMessage() } }; return { shouldContinue: false, reply: { text: buildHelpMessage(cfg) } };
} }
const commandsRequested = command.commandBodyNormalized === "/commands"; const commandsRequested = command.commandBodyNormalized === "/commands";
@ -613,7 +613,7 @@ export async function handleCommands(params: {
); );
return { shouldContinue: false }; return { shouldContinue: false };
} }
return { shouldContinue: false, reply: { text: buildCommandsMessage() } }; return { shouldContinue: false, reply: { text: buildCommandsMessage(cfg) } };
} }
const statusRequested = const statusRequested =
@ -650,6 +650,14 @@ export async function handleCommands(params: {
); );
return { shouldContinue: false }; 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") { if (configCommand.action === "error") {
return { return {
shouldContinue: false, shouldContinue: false,
@ -774,6 +782,14 @@ export async function handleCommands(params: {
); );
return { shouldContinue: false }; 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") { if (debugCommand.action === "error") {
return { return {
shouldContinue: false, shouldContinue: false,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,8 @@
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import {
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
} from "../daemon/constants.js";
import { resolveGatewayLogPaths } from "../daemon/launchd.js"; import { resolveGatewayLogPaths } from "../daemon/launchd.js";
import type { GatewayServiceRuntime } from "../daemon/service-runtime.js"; import type { GatewayServiceRuntime } from "../daemon/service-runtime.js";
import { getResolvedLoggerSettings } from "../logging.js"; import { getResolvedLoggerSettings } from "../logging.js";
@ -51,8 +55,9 @@ export function buildGatewayRuntimeHints(
} }
})(); })();
if (runtime.cachedLabel && platform === "darwin") { if (runtime.cachedLabel && platform === "darwin") {
const label = resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
hints.push( 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"); 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 stdout (if installed): ${logs.stdoutPath}`);
hints.push(`Launchd stderr (if installed): ${logs.stderrPath}`); hints.push(`Launchd stderr (if installed): ${logs.stderrPath}`);
} else if (platform === "linux") { } else if (platform === "linux") {
const unit = resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
hints.push( 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") { } 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; return hints;

View File

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

View File

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

View File

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

View File

@ -119,7 +119,9 @@ export async function statusAllCommand(
try { try {
const service = resolveGatewayService(); const service = resolveGatewayService();
const [loaded, runtimeInfo, command] = await Promise.all([ 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.readRuntime(process.env).catch(() => undefined),
service.readCommand(process.env).catch(() => null), service.readCommand(process.env).catch(() => null),
]); ]);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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