diff --git a/README.md b/README.md index 23d1827b9..87e4ed2e0 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,17 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies. - [Nix mode](https://docs.clawd.bot/nix) for declarative config; [Docker](https://docs.clawd.bot/docker)-based installs. - [Doctor](https://docs.clawd.bot/doctor) migrations, [logging](https://docs.clawd.bot/logging). +#### Gateway service (optional) + +Install a supervised service (launchd/systemd/Scheduled Task): + +```bash +clawdbot gateway install +clawdbot gateway service-status +``` + +Aliases: `clawdbot service ...` or `clawdbot daemon ...`. + ## How it works (short) ``` diff --git a/docs/clawd.md b/docs/clawd.md index 1c62d396b..58eb8e86d 100644 --- a/docs/clawd.md +++ b/docs/clawd.md @@ -69,6 +69,11 @@ clawdbot login clawdbot gateway --port 18789 ``` +Optional (supervised, always-on): +```bash +clawdbot gateway install +``` + 3) Put a minimal config in `~/.clawdbot/clawdbot.json`: ```json5 diff --git a/docs/faq.md b/docs/faq.md index 2614ebe36..eb4052b63 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -504,6 +504,11 @@ The gateway runs under a supervisor that auto-restarts it. You need to stop the - To inspect launchd state: `launchctl print gui/$UID | grep clawdbot` **macOS (CLI launchd service, if installed)** +If you haven’t installed a supervisor yet, install one first: +```bash +clawdbot gateway install +# Or: clawdbot service install +``` ```bash clawdbot gateway stop diff --git a/docs/gateway.md b/docs/gateway.md index 6fbf3aa0e..33f7e81a8 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -219,8 +219,13 @@ sudo systemctl enable --now clawdbot-gateway.service - `clawdbot gateway send --to --message "hi" [--media-url ...]` — send via Gateway (idempotent). - `clawdbot gateway agent --message "hi" [--to ...]` — run an agent turn (waits for final by default). - `clawdbot gateway call --params '{"k":"v"}'` — raw method invoker for debugging. +- `clawdbot gateway install|uninstall` — install/remove the supervised gateway service (launchd/systemd/schtasks). +- `clawdbot gateway service-status` — report supervised gateway service status. - `clawdbot gateway stop|restart` — stop/restart the supervised gateway service (launchd/systemd/schtasks). - Gateway helper subcommands assume a running gateway on `--url`; they no longer auto-spawn one. +- Service aliases: + - `clawdbot service ...` — service controls (install/uninstall/status/stop/restart) + - `clawdbot daemon ...` — alias for `clawdbot service ...` ## Migration guidance - Retire uses of `clawdbot gateway` and the legacy TCP control port. diff --git a/docs/setup.md b/docs/setup.md index e331fc47e..56569a31a 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -105,6 +105,20 @@ pnpm clawdbot health - Sessions: `~/.clawdbot/agents//sessions/` - Logs: `/tmp/clawdbot/` +## Gateway service (optional) + +If you want the Gateway supervised outside the macOS app (CLI-only, headless, or remote setups). The macOS app already manages the Gateway via launchd. + +```bash +clawdbot gateway install +clawdbot gateway service-status +clawdbot gateway uninstall +``` + +Aliases: +- `clawdbot service ...` +- `clawdbot daemon ...` + ## Updating (without wrecking your setup) - Keep `~/clawd` and `~/.clawdbot/` as “your stuff”; don’t put personal prompts/config into the `clawdbot` repo. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f119b5029..60d2fbcc9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -167,6 +167,12 @@ clawdbot gateway stop # Or: launchctl bootout gui/$UID/com.clawdbot.gateway ``` +If no supervisor is installed yet: +```bash +clawdbot gateway install +clawdbot gateway service-status +``` + **Fix 2: Check embedded gateway** Ensure the gateway relay was properly bundled. Run [`./scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) and ensure `bun` is installed. diff --git a/docs/wizard.md b/docs/wizard.md index 76ac67534..8ac799209 100644 --- a/docs/wizard.md +++ b/docs/wizard.md @@ -74,13 +74,14 @@ It does **not** install or change anything on the remote host. - DM security: default is pairing (unknown DMs get a pairing code). Approve via `clawdbot pairing approve --provider `. 6) **Daemon install** - - macOS: LaunchAgent - - Requires a logged-in user session; for headless, use a custom LaunchDaemon (not shipped). - - Linux: systemd user unit - - Wizard attempts to enable lingering via `loginctl enable-linger ` so the Gateway stays up after logout. - - May prompt for sudo (writes `/var/lib/systemd/linger`); it tries without sudo first. - - Windows: Scheduled Task - - Runs on user logon; headless/system services are not configured by default. + - macOS: LaunchAgent + - Requires a logged-in user session; for headless, use a custom LaunchDaemon (not shipped). + - Linux: systemd user unit + - Wizard attempts to enable lingering via `loginctl enable-linger ` so the Gateway stays up after logout. + - May prompt for sudo (writes `/var/lib/systemd/linger`); it tries without sudo first. + - Windows: Scheduled Task + - Runs on user logon; headless/system services are not configured by default. + - Manual alternative: `clawdbot gateway install` (or `clawdbot service install`). 7) **Health check** - Starts the Gateway (if needed) and runs `clawdbot health`. diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts index 5ae3b6ec1..280b264c6 100644 --- a/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts +++ b/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import * as sessions from "../../config/sessions.js"; import type { SessionEntry } from "../../config/sessions.js"; import * as sessions from "../../config/sessions.js"; import type { TemplateContext } from "../templating.js"; diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts index c4a134a6d..b43e1159a 100644 --- a/src/cli/gateway-cli.coverage.test.ts +++ b/src/cli/gateway-cli.coverage.test.ts @@ -13,9 +13,16 @@ const forceFreePortAndWait = vi.fn(async () => ({ waitedMs: 0, escalatedToSigkill: false, })); +const resolveGatewayProgramArguments = vi.fn(async () => ({ + programArguments: ["node", "cli", "gateway-daemon", "--port", "18789"], + workingDirectory: "/tmp", +})); +const serviceInstall = vi.fn().mockResolvedValue(undefined); const serviceStop = vi.fn().mockResolvedValue(undefined); +const serviceUninstall = vi.fn().mockResolvedValue(undefined); const serviceRestart = vi.fn().mockResolvedValue(undefined); const serviceIsLoaded = vi.fn().mockResolvedValue(true); +const serviceReadCommand = vi.fn().mockResolvedValue(null); const runtimeLogs: string[] = []; const runtimeErrors: string[] = []; @@ -77,17 +84,22 @@ vi.mock("./ports.js", () => ({ forceFreePortAndWait: (port: number) => forceFreePortAndWait(port), })); +vi.mock("../daemon/program-args.js", () => ({ + resolveGatewayProgramArguments: (params: unknown) => + resolveGatewayProgramArguments(params), +})); + vi.mock("../daemon/service.js", () => ({ resolveGatewayService: () => ({ label: "LaunchAgent", loadedText: "loaded", notLoadedText: "not loaded", - install: vi.fn(), - uninstall: vi.fn(), + install: serviceInstall, + uninstall: serviceUninstall, stop: serviceStop, restart: serviceRestart, isLoaded: serviceIsLoaded, - readCommand: vi.fn(), + readCommand: serviceReadCommand, }), })); @@ -264,6 +276,66 @@ describe("gateway-cli coverage", () => { expect(serviceRestart).toHaveBeenCalledTimes(1); }); + it("supports gateway install/uninstall for service setup", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + serviceInstall.mockClear(); + serviceUninstall.mockClear(); + serviceIsLoaded.mockResolvedValue(false); + + const { registerGatewayCli } = await import("./gateway-cli.js"); + const program = new Command(); + program.exitOverride(); + registerGatewayCli(program); + + await program.parseAsync(["gateway", "install"], { from: "user" }); + await program.parseAsync(["gateway", "uninstall"], { from: "user" }); + + expect(serviceInstall).toHaveBeenCalledTimes(1); + expect(serviceUninstall).toHaveBeenCalledTimes(1); + expect(resolveGatewayProgramArguments).toHaveBeenCalled(); + }); + + it("supports service alias commands", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + serviceStop.mockClear(); + serviceRestart.mockClear(); + serviceIsLoaded.mockResolvedValue(true); + + const { registerGatewayCli } = await import("./gateway-cli.js"); + const program = new Command(); + program.exitOverride(); + registerGatewayCli(program); + + await program.parseAsync(["service", "stop"], { from: "user" }); + await program.parseAsync(["daemon", "restart"], { from: "user" }); + + expect(serviceStop).toHaveBeenCalledTimes(1); + expect(serviceRestart).toHaveBeenCalledTimes(1); + }); + + it("prints gateway service status details when available", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + serviceReadCommand.mockResolvedValueOnce({ + programArguments: ["node", "cli", "gateway-daemon", "--port", "18789"], + workingDirectory: "/tmp", + }); + serviceIsLoaded.mockResolvedValue(true); + + const { registerGatewayCli } = await import("./gateway-cli.js"); + const program = new Command(); + program.exitOverride(); + registerGatewayCli(program); + + await program.parseAsync(["gateway", "service-status"], { from: "user" }); + + expect(runtimeLogs.join("\n")).toContain("Gateway service loaded."); + expect(runtimeLogs.join("\n")).toContain("Command:"); + expect(runtimeLogs.join("\n")).toContain("Working directory:"); + }); + it("prints stop hints on GatewayLockError when service is loaded", async () => { runtimeLogs.length = 0; runtimeErrors.length = 0; diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 6ac33db34..32cab167d 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import path from "node:path"; import type { Command } from "commander"; import { @@ -11,6 +12,7 @@ import { GATEWAY_SYSTEMD_SERVICE_NAME, GATEWAY_WINDOWS_TASK_NAME, } from "../daemon/constants.js"; +import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { startGatewayServer } from "../gateway/server.js"; @@ -36,6 +38,12 @@ type GatewayRpcOpts = { const gatewayLog = createSubsystemLogger("gateway"); type GatewayRunSignalAction = "stop" | "restart"; +type GatewayServiceInstallOpts = { + port?: unknown; + token?: unknown; + password?: unknown; + reinstall?: boolean; +}; function parsePort(raw: unknown): number | null { if (raw === undefined || raw === null) return null; @@ -106,6 +114,14 @@ function renderGatewayServiceStartHints(): string[] { } } +function renderGatewayServiceInstallHints(): string[] { + return [ + "Install with: clawdbot gateway install", + "Or: clawdbot onboard --install-daemon", + "Or: clawdbot configure", + ]; +} + async function maybeExplainGatewayServiceStop() { const service = resolveGatewayService(); let loaded: boolean | null = null; @@ -125,6 +141,151 @@ async function maybeExplainGatewayServiceStop() { } } +async function stopGatewayService() { + const service = resolveGatewayService(); + let loaded = false; + try { + loaded = await service.isLoaded({ env: process.env }); + } catch (err) { + defaultRuntime.error(`Gateway service check failed: ${String(err)}`); + defaultRuntime.exit(1); + return; + } + if (!loaded) { + defaultRuntime.log(`Gateway service ${service.notLoadedText}.`); + return; + } + try { + await service.stop({ stdout: process.stdout }); + } catch (err) { + defaultRuntime.error(`Gateway stop failed: ${String(err)}`); + defaultRuntime.exit(1); + } +} + +async function restartGatewayService() { + const service = resolveGatewayService(); + let loaded = false; + try { + loaded = await service.isLoaded({ env: process.env }); + } catch (err) { + defaultRuntime.error(`Gateway service check failed: ${String(err)}`); + defaultRuntime.exit(1); + return; + } + if (!loaded) { + defaultRuntime.log(`Gateway service ${service.notLoadedText}.`); + for (const hint of renderGatewayServiceStartHints()) { + defaultRuntime.log(`Start with: ${hint}`); + } + for (const hint of renderGatewayServiceInstallHints()) { + defaultRuntime.log(hint); + } + return; + } + try { + await service.restart({ stdout: process.stdout }); + } catch (err) { + defaultRuntime.error(`Gateway restart failed: ${String(err)}`); + defaultRuntime.exit(1); + } +} + +async function installGatewayService(opts: GatewayServiceInstallOpts) { + const cfg = loadConfig(); + const portOverride = parsePort(opts.port); + if (opts.port !== undefined && portOverride === null) { + defaultRuntime.error("Invalid port"); + defaultRuntime.exit(1); + return; + } + const port = portOverride ?? resolveGatewayPort(cfg); + if (!Number.isFinite(port) || port <= 0) { + defaultRuntime.error("Invalid port"); + defaultRuntime.exit(1); + return; + } + + const service = resolveGatewayService(); + let loaded = false; + try { + loaded = await service.isLoaded({ env: process.env }); + } catch (err) { + defaultRuntime.error(`Gateway service check failed: ${String(err)}`); + defaultRuntime.exit(1); + return; + } + if (loaded && !opts.reinstall) { + defaultRuntime.log(`Gateway service already ${service.loadedText}.`); + defaultRuntime.log("Use: clawdbot gateway uninstall (or --reinstall)"); + return; + } + if (loaded && opts.reinstall) { + await service.uninstall({ env: process.env, stdout: process.stdout }); + } + + const devMode = + process.argv[1]?.includes(`${path.sep}src${path.sep}`) && + process.argv[1]?.endsWith(".ts"); + const { programArguments, workingDirectory } = + await resolveGatewayProgramArguments({ port, dev: devMode }); + const environment: Record = { + PATH: process.env.PATH, + CLAWDBOT_GATEWAY_TOKEN: opts.token ? String(opts.token) : undefined, + CLAWDBOT_GATEWAY_PASSWORD: opts.password ? String(opts.password) : undefined, + CLAWDBOT_LAUNCHD_LABEL: + process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined, + }; + try { + await service.install({ + env: process.env, + stdout: process.stdout, + programArguments, + workingDirectory, + environment, + }); + } catch (err) { + defaultRuntime.error(`Gateway install failed: ${String(err)}`); + defaultRuntime.exit(1); + } +} + +async function uninstallGatewayService() { + const service = resolveGatewayService(); + try { + await service.uninstall({ env: process.env, stdout: process.stdout }); + } catch (err) { + defaultRuntime.error(`Gateway uninstall failed: ${String(err)}`); + defaultRuntime.exit(1); + } +} + +async function statusGatewayService() { + const service = resolveGatewayService(); + let loaded = false; + try { + loaded = await service.isLoaded({ env: process.env }); + } catch (err) { + defaultRuntime.error(`Gateway service check failed: ${String(err)}`); + defaultRuntime.exit(1); + return; + } + defaultRuntime.log( + `Gateway service ${loaded ? service.loadedText : service.notLoadedText}.`, + ); + try { + const command = await service.readCommand(process.env); + if (command?.programArguments?.length) { + defaultRuntime.log(`Command: ${command.programArguments.join(" ")}`); + } + if (command?.workingDirectory) { + defaultRuntime.log(`Working directory: ${command.workingDirectory}`); + } + } catch (err) { + defaultRuntime.error(`Gateway service status failed: ${String(err)}`); + } +} + async function runGatewayLoop(params: { start: () => Promise>>; runtime: typeof defaultRuntime; @@ -229,6 +390,51 @@ const callGatewayCli = async ( mode: "cli", }); +function registerGatewayServiceCli( + command: Command, + opts: { statusName?: string } = {}, +) { + const statusName = opts.statusName ?? "status"; + command + .command("install") + .description("Install the Gateway service (launchd/systemd/schtasks)") + .option("--port ", "Gateway port for the service") + .option("--token ", "Gateway token (optional)") + .option("--password ", "Gateway password (optional)") + .option("--reinstall", "Uninstall + reinstall if already present", false) + .action(async (options) => { + await installGatewayService(options); + }); + + command + .command("uninstall") + .description("Remove the Gateway service (launchd/systemd/schtasks)") + .action(async () => { + await uninstallGatewayService(); + }); + + command + .command(statusName) + .description("Show Gateway service status") + .action(async () => { + await statusGatewayService(); + }); + + command + .command("stop") + .description("Stop the Gateway service (launchd/systemd/schtasks)") + .action(async () => { + await stopGatewayService(); + }); + + command + .command("restart") + .description("Restart the Gateway service (launchd/systemd/schtasks)") + .action(async () => { + await restartGatewayService(); + }); +} + export function registerGatewayCli(program: Command) { program .command("gateway-daemon") @@ -733,58 +939,17 @@ export function registerGatewayCli(program: Command) { }), ); - gateway - .command("stop") - .description("Stop the Gateway service (launchd/systemd/schtasks)") - .action(async () => { - const service = resolveGatewayService(); - let loaded = false; - try { - loaded = await service.isLoaded({ env: process.env }); - } catch (err) { - defaultRuntime.error(`Gateway service check failed: ${String(err)}`); - defaultRuntime.exit(1); - return; - } - if (!loaded) { - defaultRuntime.log(`Gateway service ${service.notLoadedText}.`); - return; - } - try { - await service.stop({ stdout: process.stdout }); - } catch (err) { - defaultRuntime.error(`Gateway stop failed: ${String(err)}`); - defaultRuntime.exit(1); - } - }); + registerGatewayServiceCli(gateway, { statusName: "service-status" }); - gateway - .command("restart") - .description("Restart the Gateway service (launchd/systemd/schtasks)") - .action(async () => { - const service = resolveGatewayService(); - let loaded = false; - try { - loaded = await service.isLoaded({ env: process.env }); - } catch (err) { - defaultRuntime.error(`Gateway service check failed: ${String(err)}`); - defaultRuntime.exit(1); - return; - } - if (!loaded) { - defaultRuntime.log(`Gateway service ${service.notLoadedText}.`); - for (const hint of renderGatewayServiceStartHints()) { - defaultRuntime.log(`Start with: ${hint}`); - } - return; - } - try { - await service.restart({ stdout: process.stdout }); - } catch (err) { - defaultRuntime.error(`Gateway restart failed: ${String(err)}`); - defaultRuntime.exit(1); - } - }); + const service = program + .command("service") + .description("Gateway service controls (launchd/systemd/schtasks)"); + registerGatewayServiceCli(service); + + const daemon = program + .command("daemon") + .description("Alias for gateway service controls"); + registerGatewayServiceCli(daemon); // Build default deps (keeps parity with other commands; future-proofing). void createDefaultDeps();