Compare commits
4 Commits
main
...
fix/gemini
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47e141f03d | ||
|
|
69d8e46b84 | ||
|
|
f1a09febbf | ||
|
|
a1e7d321bc |
@ -16,6 +16,7 @@
|
|||||||
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
|
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Skills: harden skill installs with process-group timeouts, clearer timeout messaging, and logged rerun commands when installs fail.
|
||||||
- Heartbeat: default interval now 30m with a new default prompt + HEARTBEAT.md template.
|
- Heartbeat: default interval now 30m with a new default prompt + HEARTBEAT.md template.
|
||||||
- Onboarding: write auth profiles to the multi-agent path (`~/.clawdbot/agents/main/agent/`) so the gateway finds credentials on first startup. Thanks @minghinmatthewlam for PR #327.
|
- Onboarding: write auth profiles to the multi-agent path (`~/.clawdbot/agents/main/agent/`) so the gateway finds credentials on first startup. Thanks @minghinmatthewlam for PR #327.
|
||||||
- Docs: add missing `ui:install` setup step in the README. Thanks @hugobarauna for PR #300.
|
- Docs: add missing `ui:install` setup step in the README. Thanks @hugobarauna for PR #300.
|
||||||
|
|||||||
11
README.md
11
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.
|
- [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).
|
- [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)
|
## How it works (short)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@ -69,6 +69,11 @@ clawdbot login
|
|||||||
clawdbot gateway --port 18789
|
clawdbot gateway --port 18789
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Optional (supervised, always-on):
|
||||||
|
```bash
|
||||||
|
clawdbot gateway install
|
||||||
|
```
|
||||||
|
|
||||||
3) Put a minimal config in `~/.clawdbot/clawdbot.json`:
|
3) Put a minimal config in `~/.clawdbot/clawdbot.json`:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
|
|||||||
@ -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`
|
- To inspect launchd state: `launchctl print gui/$UID | grep clawdbot`
|
||||||
|
|
||||||
**macOS (CLI launchd service, if installed)**
|
**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
|
```bash
|
||||||
clawdbot gateway stop
|
clawdbot gateway stop
|
||||||
|
|||||||
@ -219,8 +219,13 @@ sudo systemctl enable --now clawdbot-gateway.service
|
|||||||
- `clawdbot gateway send --to <num> --message "hi" [--media-url ...]` — send via Gateway (idempotent).
|
- `clawdbot gateway send --to <num> --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 agent --message "hi" [--to ...]` — run an agent turn (waits for final by default).
|
||||||
- `clawdbot gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging.
|
- `clawdbot gateway call <method> --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).
|
- `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.
|
- 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
|
## Migration guidance
|
||||||
- Retire uses of `clawdbot gateway` and the legacy TCP control port.
|
- Retire uses of `clawdbot gateway` and the legacy TCP control port.
|
||||||
|
|||||||
@ -105,6 +105,20 @@ pnpm clawdbot health
|
|||||||
- Sessions: `~/.clawdbot/agents/<agentId>/sessions/`
|
- Sessions: `~/.clawdbot/agents/<agentId>/sessions/`
|
||||||
- Logs: `/tmp/clawdbot/`
|
- 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)
|
## Updating (without wrecking your setup)
|
||||||
|
|
||||||
- Keep `~/clawd` and `~/.clawdbot/` as “your stuff”; don’t put personal prompts/config into the `clawdbot` repo.
|
- Keep `~/clawd` and `~/.clawdbot/` as “your stuff”; don’t put personal prompts/config into the `clawdbot` repo.
|
||||||
|
|||||||
3
docs/slash-commands/landpr.md
Normal file
3
docs/slash-commands/landpr.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# /landpr
|
||||||
|
|
||||||
|
Land PR: rebase onto temp branch from main, fix+tests+changelog, run pnpm lint && pnpm build && pnpm test before commit, commit via committer, fast‑forward main, push, close PR, delete temp branch.
|
||||||
@ -167,6 +167,12 @@ clawdbot gateway stop
|
|||||||
# Or: launchctl bootout gui/$UID/com.clawdbot.gateway
|
# 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**
|
**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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@ -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 <provider> <code>`.
|
- DM security: default is pairing (unknown DMs get a pairing code). Approve via `clawdbot pairing approve --provider <provider> <code>`.
|
||||||
|
|
||||||
6) **Daemon install**
|
6) **Daemon install**
|
||||||
- macOS: LaunchAgent
|
- macOS: LaunchAgent
|
||||||
- Requires a logged-in user session; for headless, use a custom LaunchDaemon (not shipped).
|
- Requires a logged-in user session; for headless, use a custom LaunchDaemon (not shipped).
|
||||||
- Linux: systemd user unit
|
- Linux: systemd user unit
|
||||||
- Wizard attempts to enable lingering via `loginctl enable-linger <user>` so the Gateway stays up after logout.
|
- Wizard attempts to enable lingering via `loginctl enable-linger <user>` so the Gateway stays up after logout.
|
||||||
- May prompt for sudo (writes `/var/lib/systemd/linger`); it tries without sudo first.
|
- May prompt for sudo (writes `/var/lib/systemd/linger`); it tries without sudo first.
|
||||||
- Windows: Scheduled Task
|
- Windows: Scheduled Task
|
||||||
- Runs on user logon; headless/system services are not configured by default.
|
- Runs on user logon; headless/system services are not configured by default.
|
||||||
|
- Manual alternative: `clawdbot gateway install` (or `clawdbot service install`).
|
||||||
|
|
||||||
7) **Health check**
|
7) **Health check**
|
||||||
- Starts the Gateway (if needed) and runs `clawdbot health`.
|
- Starts the Gateway (if needed) and runs `clawdbot health`.
|
||||||
|
|||||||
@ -12,7 +12,7 @@ process.stdout.on('error', (error) => {
|
|||||||
|
|
||||||
const DOCS_DIR = join(process.cwd(), 'docs');
|
const DOCS_DIR = join(process.cwd(), 'docs');
|
||||||
|
|
||||||
const EXCLUDED_DIRS = new Set(['archive', 'research']);
|
const EXCLUDED_DIRS = new Set(['archive', 'research', 'slash-commands']);
|
||||||
|
|
||||||
function compactStrings(values: unknown[]): string[] {
|
function compactStrings(values: unknown[]): string[] {
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
|
|||||||
@ -27,6 +27,7 @@ export type SkillInstallResult = {
|
|||||||
stdout: string;
|
stdout: string;
|
||||||
stderr: string;
|
stderr: string;
|
||||||
code: number | null;
|
code: number | null;
|
||||||
|
command?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function summarizeInstallOutput(text: string): string | undefined {
|
function summarizeInstallOutput(text: string): string | undefined {
|
||||||
@ -55,7 +56,22 @@ function formatInstallFailureMessage(result: {
|
|||||||
code: number | null;
|
code: number | null;
|
||||||
stdout: string;
|
stdout: string;
|
||||||
stderr: string;
|
stderr: string;
|
||||||
|
signal?: NodeJS.Signals | null;
|
||||||
|
killed?: boolean;
|
||||||
|
timedOut?: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
}): string {
|
}): string {
|
||||||
|
const isTimeout =
|
||||||
|
result.timedOut || result.signal === "SIGKILL" || result.killed === true;
|
||||||
|
if (isTimeout) {
|
||||||
|
const seconds =
|
||||||
|
typeof result.timeoutMs === "number"
|
||||||
|
? Math.max(1, Math.round(result.timeoutMs / 1000))
|
||||||
|
: undefined;
|
||||||
|
return seconds
|
||||||
|
? `Install timed out after ${seconds}s`
|
||||||
|
: "Install timed out";
|
||||||
|
}
|
||||||
const code =
|
const code =
|
||||||
typeof result.code === "number" ? `exit ${result.code}` : "unknown exit";
|
typeof result.code === "number" ? `exit ${result.code}` : "unknown exit";
|
||||||
const summary =
|
const summary =
|
||||||
@ -127,6 +143,15 @@ function buildInstallCommand(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCommand(argv: string[]): string {
|
||||||
|
return argv
|
||||||
|
.map((arg) => {
|
||||||
|
if (/^[A-Za-z0-9_./:=+-]+$/.test(arg)) return arg;
|
||||||
|
return `'${arg.replace(/'/g, `'\\''`)}'`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveBrewBinDir(
|
async function resolveBrewBinDir(
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
@ -194,6 +219,16 @@ export async function installSkill(
|
|||||||
code: null,
|
code: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (!command.argv || command.argv.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "invalid install command",
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
code: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const commandLine = formatCommand(command.argv);
|
||||||
if (spec.kind === "brew" && !hasBinary("brew")) {
|
if (spec.kind === "brew" && !hasBinary("brew")) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@ -201,6 +236,7 @@ export async function installSkill(
|
|||||||
stdout: "",
|
stdout: "",
|
||||||
stderr: "",
|
stderr: "",
|
||||||
code: null,
|
code: null,
|
||||||
|
command: commandLine,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (spec.kind === "uv" && !hasBinary("uv")) {
|
if (spec.kind === "uv" && !hasBinary("uv")) {
|
||||||
@ -218,6 +254,7 @@ export async function installSkill(
|
|||||||
stdout: brewResult.stdout.trim(),
|
stdout: brewResult.stdout.trim(),
|
||||||
stderr: brewResult.stderr.trim(),
|
stderr: brewResult.stderr.trim(),
|
||||||
code: brewResult.code,
|
code: brewResult.code,
|
||||||
|
command: formatCommand(["brew", "install", "uv"]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -227,18 +264,10 @@ export async function installSkill(
|
|||||||
stdout: "",
|
stdout: "",
|
||||||
stderr: "",
|
stderr: "",
|
||||||
code: null,
|
code: null,
|
||||||
|
command: commandLine,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!command.argv || command.argv.length === 0) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
message: "invalid install command",
|
|
||||||
stdout: "",
|
|
||||||
stderr: "",
|
|
||||||
code: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (spec.kind === "go" && !hasBinary("go")) {
|
if (spec.kind === "go" && !hasBinary("go")) {
|
||||||
if (hasBinary("brew")) {
|
if (hasBinary("brew")) {
|
||||||
@ -255,6 +284,7 @@ export async function installSkill(
|
|||||||
stdout: brewResult.stdout.trim(),
|
stdout: brewResult.stdout.trim(),
|
||||||
stderr: brewResult.stderr.trim(),
|
stderr: brewResult.stderr.trim(),
|
||||||
code: brewResult.code,
|
code: brewResult.code,
|
||||||
|
command: formatCommand(["brew", "install", "go"]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -264,6 +294,7 @@ export async function installSkill(
|
|||||||
stdout: "",
|
stdout: "",
|
||||||
stderr: "",
|
stderr: "",
|
||||||
code: null,
|
code: null,
|
||||||
|
command: commandLine,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -277,7 +308,14 @@ export async function installSkill(
|
|||||||
const result = await (async () => {
|
const result = await (async () => {
|
||||||
const argv = command.argv;
|
const argv = command.argv;
|
||||||
if (!argv || argv.length === 0) {
|
if (!argv || argv.length === 0) {
|
||||||
return { code: null, stdout: "", stderr: "invalid install command" };
|
return {
|
||||||
|
code: null,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "invalid install command",
|
||||||
|
signal: null,
|
||||||
|
killed: false,
|
||||||
|
timedOut: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await runCommandWithTimeout(argv, {
|
return await runCommandWithTimeout(argv, {
|
||||||
@ -286,16 +324,26 @@ export async function installSkill(
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const stderr = err instanceof Error ? err.message : String(err);
|
const stderr = err instanceof Error ? err.message : String(err);
|
||||||
return { code: null, stdout: "", stderr };
|
return {
|
||||||
|
code: null,
|
||||||
|
stdout: "",
|
||||||
|
stderr,
|
||||||
|
signal: null,
|
||||||
|
killed: false,
|
||||||
|
timedOut: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const success = result.code === 0;
|
const success = result.code === 0 && !result.timedOut;
|
||||||
return {
|
return {
|
||||||
ok: success,
|
ok: success,
|
||||||
message: success ? "Installed" : formatInstallFailureMessage(result),
|
message: success
|
||||||
|
? "Installed"
|
||||||
|
: formatInstallFailureMessage({ ...result, timeoutMs }),
|
||||||
stdout: result.stdout.trim(),
|
stdout: result.stdout.trim(),
|
||||||
stderr: result.stderr.trim(),
|
stderr: result.stderr.trim(),
|
||||||
code: result.code,
|
code: result.code,
|
||||||
|
command: commandLine,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,9 +13,16 @@ const forceFreePortAndWait = vi.fn(async () => ({
|
|||||||
waitedMs: 0,
|
waitedMs: 0,
|
||||||
escalatedToSigkill: false,
|
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 serviceStop = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const serviceUninstall = vi.fn().mockResolvedValue(undefined);
|
||||||
const serviceRestart = vi.fn().mockResolvedValue(undefined);
|
const serviceRestart = vi.fn().mockResolvedValue(undefined);
|
||||||
const serviceIsLoaded = vi.fn().mockResolvedValue(true);
|
const serviceIsLoaded = vi.fn().mockResolvedValue(true);
|
||||||
|
const serviceReadCommand = vi.fn().mockResolvedValue(null);
|
||||||
|
|
||||||
const runtimeLogs: string[] = [];
|
const runtimeLogs: string[] = [];
|
||||||
const runtimeErrors: string[] = [];
|
const runtimeErrors: string[] = [];
|
||||||
@ -77,17 +84,22 @@ vi.mock("./ports.js", () => ({
|
|||||||
forceFreePortAndWait: (port: number) => forceFreePortAndWait(port),
|
forceFreePortAndWait: (port: number) => forceFreePortAndWait(port),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../daemon/program-args.js", () => ({
|
||||||
|
resolveGatewayProgramArguments: (params: unknown) =>
|
||||||
|
resolveGatewayProgramArguments(params),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../daemon/service.js", () => ({
|
vi.mock("../daemon/service.js", () => ({
|
||||||
resolveGatewayService: () => ({
|
resolveGatewayService: () => ({
|
||||||
label: "LaunchAgent",
|
label: "LaunchAgent",
|
||||||
loadedText: "loaded",
|
loadedText: "loaded",
|
||||||
notLoadedText: "not loaded",
|
notLoadedText: "not loaded",
|
||||||
install: vi.fn(),
|
install: serviceInstall,
|
||||||
uninstall: vi.fn(),
|
uninstall: serviceUninstall,
|
||||||
stop: serviceStop,
|
stop: serviceStop,
|
||||||
restart: serviceRestart,
|
restart: serviceRestart,
|
||||||
isLoaded: serviceIsLoaded,
|
isLoaded: serviceIsLoaded,
|
||||||
readCommand: vi.fn(),
|
readCommand: serviceReadCommand,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -264,6 +276,66 @@ describe("gateway-cli coverage", () => {
|
|||||||
expect(serviceRestart).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("prints stop hints on GatewayLockError when service is loaded", async () => {
|
||||||
runtimeLogs.length = 0;
|
runtimeLogs.length = 0;
|
||||||
runtimeErrors.length = 0;
|
runtimeErrors.length = 0;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import {
|
import {
|
||||||
@ -11,6 +12,7 @@ import {
|
|||||||
GATEWAY_SYSTEMD_SERVICE_NAME,
|
GATEWAY_SYSTEMD_SERVICE_NAME,
|
||||||
GATEWAY_WINDOWS_TASK_NAME,
|
GATEWAY_WINDOWS_TASK_NAME,
|
||||||
} from "../daemon/constants.js";
|
} from "../daemon/constants.js";
|
||||||
|
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
||||||
import { startGatewayServer } from "../gateway/server.js";
|
import { startGatewayServer } from "../gateway/server.js";
|
||||||
@ -36,6 +38,12 @@ type GatewayRpcOpts = {
|
|||||||
const gatewayLog = createSubsystemLogger("gateway");
|
const gatewayLog = createSubsystemLogger("gateway");
|
||||||
|
|
||||||
type GatewayRunSignalAction = "stop" | "restart";
|
type GatewayRunSignalAction = "stop" | "restart";
|
||||||
|
type GatewayServiceInstallOpts = {
|
||||||
|
port?: unknown;
|
||||||
|
token?: unknown;
|
||||||
|
password?: unknown;
|
||||||
|
reinstall?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function parsePort(raw: unknown): number | null {
|
function parsePort(raw: unknown): number | null {
|
||||||
if (raw === undefined || raw === null) return 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() {
|
async function maybeExplainGatewayServiceStop() {
|
||||||
const service = resolveGatewayService();
|
const service = resolveGatewayService();
|
||||||
let loaded: boolean | null = null;
|
let loaded: boolean | null = null;
|
||||||
@ -125,6 +141,153 @@ 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<string, string | undefined> = {
|
||||||
|
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: {
|
async function runGatewayLoop(params: {
|
||||||
start: () => Promise<Awaited<ReturnType<typeof startGatewayServer>>>;
|
start: () => Promise<Awaited<ReturnType<typeof startGatewayServer>>>;
|
||||||
runtime: typeof defaultRuntime;
|
runtime: typeof defaultRuntime;
|
||||||
@ -229,6 +392,51 @@ const callGatewayCli = async (
|
|||||||
mode: "cli",
|
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 <port>", "Gateway port for the service")
|
||||||
|
.option("--token <token>", "Gateway token (optional)")
|
||||||
|
.option("--password <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) {
|
export function registerGatewayCli(program: Command) {
|
||||||
program
|
program
|
||||||
.command("gateway-daemon")
|
.command("gateway-daemon")
|
||||||
@ -733,58 +941,17 @@ export function registerGatewayCli(program: Command) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
gateway
|
registerGatewayServiceCli(gateway, { statusName: "service-status" });
|
||||||
.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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
gateway
|
const service = program
|
||||||
.command("restart")
|
.command("service")
|
||||||
.description("Restart the Gateway service (launchd/systemd/schtasks)")
|
.description("Gateway service controls (launchd/systemd/schtasks)");
|
||||||
.action(async () => {
|
registerGatewayServiceCli(service);
|
||||||
const service = resolveGatewayService();
|
|
||||||
let loaded = false;
|
const daemon = program
|
||||||
try {
|
.command("daemon")
|
||||||
loaded = await service.isLoaded({ env: process.env });
|
.description("Alias for gateway service controls");
|
||||||
} catch (err) {
|
registerGatewayServiceCli(daemon);
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build default deps (keeps parity with other commands; future-proofing).
|
// Build default deps (keeps parity with other commands; future-proofing).
|
||||||
void createDefaultDeps();
|
void createDefaultDeps();
|
||||||
|
|||||||
@ -163,6 +163,9 @@ export async function setupSkills(
|
|||||||
spin.stop(
|
spin.stop(
|
||||||
`Install failed: ${name}${code}${detail ? ` — ${detail}` : ""}`,
|
`Install failed: ${name}${code}${detail ? ` — ${detail}` : ""}`,
|
||||||
);
|
);
|
||||||
|
if (result.command) {
|
||||||
|
runtime.log(`Command: ${result.command}`);
|
||||||
|
}
|
||||||
if (result.stderr) runtime.log(result.stderr.trim());
|
if (result.stderr) runtime.log(result.stderr.trim());
|
||||||
else if (result.stdout) runtime.log(result.stdout.trim());
|
else if (result.stdout) runtime.log(result.stdout.trim());
|
||||||
runtime.log(
|
runtime.log(
|
||||||
|
|||||||
@ -18,5 +18,16 @@ describe("runCommandWithTimeout", () => {
|
|||||||
|
|
||||||
expect(result.code).toBe(0);
|
expect(result.code).toBe(0);
|
||||||
expect(result.stdout).toBe("ok");
|
expect(result.stdout).toBe("ok");
|
||||||
|
expect(result.timedOut).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks timed out processes", async () => {
|
||||||
|
if (process.platform === "win32") return;
|
||||||
|
const result = await runCommandWithTimeout(
|
||||||
|
[process.execPath, "-e", "setTimeout(() => {}, 1000)"],
|
||||||
|
{ timeoutMs: 50 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.timedOut).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -41,6 +41,7 @@ export type SpawnResult = {
|
|||||||
code: number | null;
|
code: number | null;
|
||||||
signal: NodeJS.Signals | null;
|
signal: NodeJS.Signals | null;
|
||||||
killed: boolean;
|
killed: boolean;
|
||||||
|
timedOut: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CommandOptions = {
|
export type CommandOptions = {
|
||||||
@ -60,18 +61,42 @@ export async function runCommandWithTimeout(
|
|||||||
: optionsOrTimeout;
|
: optionsOrTimeout;
|
||||||
const { timeoutMs, cwd, input, env } = options;
|
const { timeoutMs, cwd, input, env } = options;
|
||||||
|
|
||||||
|
const supportsGroupKill = process.platform !== "win32";
|
||||||
|
const killGraceMs = 5_000;
|
||||||
|
|
||||||
// Spawn with inherited stdin (TTY) so tools like `pi` stay interactive when needed.
|
// Spawn with inherited stdin (TTY) so tools like `pi` stay interactive when needed.
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const child = spawn(argv[0], argv.slice(1), {
|
const child = spawn(argv[0], argv.slice(1), {
|
||||||
stdio: [input ? "pipe" : "inherit", "pipe", "pipe"],
|
stdio: [input ? "pipe" : "inherit", "pipe", "pipe"],
|
||||||
cwd,
|
cwd,
|
||||||
env: env ? { ...process.env, ...env } : process.env,
|
env: env ? { ...process.env, ...env } : process.env,
|
||||||
|
detached: supportsGroupKill,
|
||||||
});
|
});
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
let timedOut = false;
|
||||||
|
let killTimer: NodeJS.Timeout | undefined;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
child.kill("SIGKILL");
|
if (settled) return;
|
||||||
|
timedOut = true;
|
||||||
|
const pid = child.pid;
|
||||||
|
if (pid) {
|
||||||
|
try {
|
||||||
|
if (supportsGroupKill) process.kill(-pid, "SIGTERM");
|
||||||
|
else child.kill("SIGTERM");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
killTimer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (supportsGroupKill) process.kill(-pid, "SIGKILL");
|
||||||
|
else child.kill("SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, killGraceMs);
|
||||||
|
}
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
if (input && child.stdin) {
|
if (input && child.stdin) {
|
||||||
@ -89,13 +114,22 @@ export async function runCommandWithTimeout(
|
|||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
if (killTimer) clearTimeout(killTimer);
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
child.on("close", (code, signal) => {
|
child.on("close", (code, signal) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
resolve({ stdout, stderr, code, signal, killed: child.killed });
|
if (killTimer) clearTimeout(killTimer);
|
||||||
|
resolve({
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
code,
|
||||||
|
signal,
|
||||||
|
killed: child.killed,
|
||||||
|
timedOut,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user