diff --git a/docs/PLUGIN_SPEC.md b/docs/PLUGIN_SPEC.md index b1ce22d34..18f82cca6 100644 --- a/docs/PLUGIN_SPEC.md +++ b/docs/PLUGIN_SPEC.md @@ -7,10 +7,10 @@ This document defines the runtime contract and packaging requirements for Agent Plugins are standard Node.js modules that export a `PluginModule`: ```ts -export interface PluginModule { +export interface PluginModule { manifest: PluginManifest; - create(config?: Record): unknown; - detect?(input?: unknown): Promise | unknown; + create(config?: Record): T; + detect?(): boolean; } ``` diff --git a/packages/cli/__tests__/commands/doctor.test.ts b/packages/cli/__tests__/commands/doctor.test.ts index d965a7684..b55dc2e6f 100644 --- a/packages/cli/__tests__/commands/doctor.test.ts +++ b/packages/cli/__tests__/commands/doctor.test.ts @@ -12,11 +12,12 @@ vi.mock("../../src/lib/script-runner.js", () => ({ vi.mock("@composio/ao-core", () => ({ findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args), + getObservabilityBaseDir: () => "/tmp/.agent-orchestrator/observability", loadConfig: vi.fn(), })); vi.mock("../../src/lib/openclaw-probe.js", () => ({ - probeGateway: vi.fn(), + detectOpenClawInstallation: vi.fn(), validateToken: vi.fn(), })); diff --git a/packages/cli/__tests__/commands/setup.test.ts b/packages/cli/__tests__/commands/setup.test.ts index a5c776f5d..ef399e226 100644 --- a/packages/cli/__tests__/commands/setup.test.ts +++ b/packages/cli/__tests__/commands/setup.test.ts @@ -19,9 +19,10 @@ const { mockReadFileSync, mockWriteFileSync, mockExistsSync, mockMkdirSync } = v mockMkdirSync: vi.fn(), })); -const { mockProbeGateway, mockValidateToken } = vi.hoisted(() => ({ +const { mockProbeGateway, mockValidateToken, mockDetectOpenClawInstallation } = vi.hoisted(() => ({ mockProbeGateway: vi.fn(), mockValidateToken: vi.fn(), + mockDetectOpenClawInstallation: vi.fn(), })); vi.mock("@composio/ao-core", () => ({ @@ -42,6 +43,7 @@ vi.mock("node:fs", async (importOriginal) => { vi.mock("../../src/lib/openclaw-probe.js", () => ({ probeGateway: (...args: unknown[]) => mockProbeGateway(...args), validateToken: (...args: unknown[]) => mockValidateToken(...args), + detectOpenClawInstallation: (...args: unknown[]) => mockDetectOpenClawInstallation(...args), DEFAULT_OPENCLAW_URL: "http://127.0.0.1:18789", HOOKS_PATH: "/hooks/agent", })); @@ -282,6 +284,60 @@ describe("setup openclaw command", () => { expect(writtenYaml).toContain("wakeMode: now"); }); + it("defaults OpenClaw routing to urgent + action only", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--token", + "tok", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notificationRouting?: Record; + }; + + expect(parsed.notificationRouting?.["urgent"]).toContain("openclaw"); + expect(parsed.notificationRouting?.["action"]).toContain("openclaw"); + expect(parsed.notificationRouting?.["warning"]).not.toContain("openclaw"); + expect(parsed.notificationRouting?.["info"]).not.toContain("openclaw"); + }); + + it("supports overriding the routing preset in non-interactive mode", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--token", + "tok", + "--routing-preset", + "all", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notificationRouting?: Record; + }; + + expect(parsed.notificationRouting?.["urgent"]).toContain("openclaw"); + expect(parsed.notificationRouting?.["action"]).toContain("openclaw"); + expect(parsed.notificationRouting?.["warning"]).toContain("openclaw"); + expect(parsed.notificationRouting?.["info"]).toContain("openclaw"); + }); + it("merges existing allowedSessionKeyPrefixes in openclaw.json", async () => { const openclawConfigPath = join(homedir(), ".openclaw", "openclaw.json"); @@ -427,7 +483,12 @@ describe("setup openclaw command", () => { expect(mockWriteFileSync).toHaveBeenCalled(); }); - it("exits when --url missing in non-interactive mode", async () => { + it("exits when --url missing and gateway unreachable in non-interactive mode", async () => { + mockDetectOpenClawInstallation.mockResolvedValue({ + state: "missing", + gatewayUrl: "http://127.0.0.1:18789", + probe: { reachable: false, error: "ECONNREFUSED" }, + }); const program = createProgram(); const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index c2e87a592..a869dc56a 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -44,6 +44,10 @@ const { mockStopLifecycleWorker: vi.fn(), })); +const { mockDetectOpenClawInstallation } = vi.hoisted(() => ({ + mockDetectOpenClawInstallation: vi.fn(), +})); + vi.mock("../../src/lib/shell.js", () => ({ tmux: vi.fn(), exec: mockExec, @@ -149,6 +153,10 @@ vi.mock("../../src/lib/project-detection.js", () => ({ formatProjectTypeForDisplay: vi.fn().mockReturnValue(""), })); +vi.mock("../../src/lib/openclaw-probe.js", () => ({ + detectOpenClawInstallation: (...args: unknown[]) => mockDetectOpenClawInstallation(...args), +})); + // Mock node:child_process — start.ts imports spawn for dashboard + browser open vi.mock("node:child_process", async (importOriginal) => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports @@ -215,6 +223,12 @@ beforeEach(() => { }); mockStopLifecycleWorker.mockReset(); mockStopLifecycleWorker.mockResolvedValue(true); + mockDetectOpenClawInstallation.mockReset(); + mockDetectOpenClawInstallation.mockResolvedValue({ + state: "missing", + gatewayUrl: "http://127.0.0.1:18789", + probe: { reachable: false, error: "not running" }, + }); mockSpawn.mockClear(); }); @@ -367,6 +381,50 @@ describe("start command — project resolution", () => { }); }); +describe("start command — OpenClaw preflight", () => { + it("warns when OpenClaw is configured but offline", async () => { + mockConfigRef.current = { + ...makeConfig({ "my-app": makeProject() }), + notifiers: { + openclaw: { + plugin: "openclaw", + url: "http://127.0.0.1:18789/hooks/agent", + }, + }, + }; + mockDetectOpenClawInstallation.mockResolvedValue({ + state: "installed-but-stopped", + gatewayUrl: "http://127.0.0.1:18789", + probe: { reachable: false, error: "not running" }, + }); + + await program.parseAsync(["node", "test", "start", "--no-dashboard", "--no-orchestrator"]); + + const output = vi + .mocked(console.log) + .mock.calls.map((c) => c.join(" ")) + .join("\n"); + expect(output).toContain("OpenClaw is configured but the gateway is not reachable"); + }); + + it("suggests setup when OpenClaw is running but not configured", async () => { + mockConfigRef.current = makeConfig({ "my-app": makeProject() }); + mockDetectOpenClawInstallation.mockResolvedValue({ + state: "running", + gatewayUrl: "http://127.0.0.1:18789", + probe: { reachable: true, httpStatus: 200 }, + }); + + await program.parseAsync(["node", "test", "start", "--no-dashboard", "--no-orchestrator"]); + + const output = vi + .mocked(console.log) + .mock.calls.map((c) => c.join(" ")) + .join("\n"); + expect(output).toContain("ao setup openclaw"); + }); +}); + // --------------------------------------------------------------------------- // URL detection — `ao start ` triggers handleUrlStart // --------------------------------------------------------------------------- diff --git a/packages/cli/__tests__/lib/openclaw-probe.test.ts b/packages/cli/__tests__/lib/openclaw-probe.test.ts index 037d428a2..85b9fad92 100644 --- a/packages/cli/__tests__/lib/openclaw-probe.test.ts +++ b/packages/cli/__tests__/lib/openclaw-probe.test.ts @@ -1,9 +1,39 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { probeGateway, validateToken } from "../../src/lib/openclaw-probe.js"; + +const { mockExistsSync, mockSpawnSync } = vi.hoisted(() => ({ + mockExistsSync: vi.fn(), + mockSpawnSync: vi.fn(), +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + existsSync: (...args: unknown[]) => mockExistsSync(...args), + }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + spawnSync: (...args: unknown[]) => mockSpawnSync(...args), + }; +}); + +import { + detectOpenClawInstallation, + probeGateway, + validateToken, +} from "../../src/lib/openclaw-probe.js"; describe("openclaw-probe", () => { afterEach(() => { vi.unstubAllGlobals(); + mockExistsSync.mockReset(); + mockExistsSync.mockReturnValue(false); + mockSpawnSync.mockReset(); + mockSpawnSync.mockReturnValue({ status: 1, stdout: "" }); }); describe("probeGateway", () => { @@ -65,6 +95,51 @@ describe("openclaw-probe", () => { expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:18789"); }); + + it("strips /hooks/agent when probing a hooks URL", async () => { + const fetchMock = vi.fn().mockResolvedValue({ status: 200, ok: true }); + vi.stubGlobal("fetch", fetchMock); + + await probeGateway("http://127.0.0.1:18789/hooks/agent"); + + expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:18789"); + }); + }); + + describe("detectOpenClawInstallation", () => { + it("reports missing when binary/config are absent and gateway is down", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + vi.stubGlobal("fetch", fetchMock); + + const result = await detectOpenClawInstallation(); + + expect(result.state).toBe("missing"); + expect(result.binaryPath).toBeUndefined(); + expect(result.configPath).toBeUndefined(); + }); + + it("reports installed-but-stopped when binary exists but gateway is down", async () => { + mockSpawnSync.mockReturnValue({ status: 0, stdout: "/usr/local/bin/openclaw\n" }); + const fetchMock = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + vi.stubGlobal("fetch", fetchMock); + + const result = await detectOpenClawInstallation(); + + expect(result.state).toBe("installed-but-stopped"); + expect(result.binaryPath).toBe("/usr/local/bin/openclaw"); + }); + + it("reports running when gateway is reachable", async () => { + mockSpawnSync.mockReturnValue({ status: 0, stdout: "/usr/local/bin/openclaw\n" }); + mockExistsSync.mockReturnValue(true); + const fetchMock = vi.fn().mockResolvedValue({ status: 200, ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const result = await detectOpenClawInstallation(); + + expect(result.state).toBe("running"); + expect(result.configPath).toContain(".openclaw/openclaw.json"); + }); }); describe("validateToken", () => { diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 65e416327..4c13bb48b 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,8 +1,17 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; import type { Command } from "commander"; import chalk from "chalk"; -import { findConfigFile, loadConfig, type Notifier, type OrchestratorConfig } from "@composio/ao-core"; +import { + findConfigFile, + getObservabilityBaseDir, + loadConfig, + type Notifier, + type OrchestratorConfig, +} from "@composio/ao-core"; import { runRepoScript } from "../lib/script-runner.js"; -import { probeGateway, validateToken } from "../lib/openclaw-probe.js"; +import { detectOpenClawInstallation, validateToken } from "../lib/openclaw-probe.js"; +import { importPluginModuleFromSource } from "../lib/plugin-store.js"; // --------------------------------------------------------------------------- // Helpers — match the PASS / WARN / FAIL style of ao-doctor.sh @@ -34,6 +43,41 @@ function makeFailCounter(): { fail: (msg: string) => void; count: () => number } // Notifier connectivity checks (Gap 2) // --------------------------------------------------------------------------- +interface OpenClawHealthSummary { + lastSuccessAt: string | null; + lastFailureAt: string | null; + lastFailureError: string | null; + totalSent: number; + totalFailed: number; +} + +function formatTimestamp(timestamp: string | null): string | null { + if (!timestamp) return null; + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) ? timestamp : date.toLocaleString(); +} + +function readOpenClawHealth(config: OrchestratorConfig): OpenClawHealthSummary | null { + if (!config.configPath) return null; + const healthPath = join(getObservabilityBaseDir(config.configPath), "openclaw-health.json"); + if (!existsSync(healthPath)) return null; + + try { + const raw = readFileSync(healthPath, "utf-8"); + const parsed = JSON.parse(raw) as Record; + if (typeof parsed !== "object" || parsed === null) return null; + return { + lastSuccessAt: typeof parsed.lastSuccessAt === "string" ? parsed.lastSuccessAt : null, + lastFailureAt: typeof parsed.lastFailureAt === "string" ? parsed.lastFailureAt : null, + lastFailureError: typeof parsed.lastFailureError === "string" ? parsed.lastFailureError : null, + totalSent: typeof parsed.totalSent === "number" ? parsed.totalSent : 0, + totalFailed: typeof parsed.totalFailed === "number" ? parsed.totalFailed : 0, + }; + } catch { + return null; + } +} + async function checkOpenClawNotifier( config: OrchestratorConfig, fail: (msg: string) => void, @@ -53,33 +97,58 @@ async function checkOpenClawNotifier( const envVarMatch = rawToken?.match(/^\$\{([^}]+)\}$/); const token = (envVarMatch ? process.env[envVarMatch[1]] : rawToken) ?? process.env["OPENCLAW_HOOKS_TOKEN"]; - // Step 1: Probe gateway reachability - const probe = await probeGateway(url); - if (!probe.reachable) { - fail( - `OpenClaw gateway is not reachable at ${url}. ` + - `Fix: ensure OpenClaw is running (openclaw status) or fix the URL in your config`, + const installation = await detectOpenClawInstallation(url); + if (installation.state === "running") { + pass( + `OpenClaw gateway detected at ${installation.gatewayUrl} (HTTP ${installation.probe.httpStatus})`, + ); + } else if (installation.state === "installed-but-stopped") { + const installHint = installation.binaryPath + ? `installed at ${installation.binaryPath}` + : "configured on this machine"; + fail(`OpenClaw is ${installHint} but the gateway is not running at ${installation.gatewayUrl}`); + } else { + fail( + `OpenClaw is not installed locally and the gateway is not reachable at ${installation.gatewayUrl}. ` + + `Fix: install/start OpenClaw or update the notifier URL`, ); - return; } - pass(`OpenClaw gateway is reachable at ${url} (HTTP ${probe.httpStatus})`); - // Step 2: Validate auth token if present if (!token) { warn( "OpenClaw token is not set. Fix: set OPENCLAW_HOOKS_TOKEN env var or add token to notifiers.openclaw in config", ); + } else if (installation.state === "running") { + const tokenResult = await validateToken(installation.gatewayUrl, token); + if (!tokenResult.valid) { + fail(`OpenClaw token validation failed: ${tokenResult.error}`); + } else { + pass("OpenClaw token is valid"); + } + } + + const health = readOpenClawHealth(config); + if (!health) { + warn("No OpenClaw notification history recorded yet"); return; } - const tokenResult = await validateToken(url, token); - if (!tokenResult.valid) { - fail(`OpenClaw token validation failed: ${tokenResult.error}`); - return; + const lastSuccess = formatTimestamp(health.lastSuccessAt); + if (lastSuccess) { + pass(`OpenClaw last successful notification: ${lastSuccess}`); + } else { + warn("OpenClaw has not recorded a successful notification yet"); } - pass("OpenClaw token is valid"); + if (health.lastFailureAt) { + const lastFailure = formatTimestamp(health.lastFailureAt); + warn( + `OpenClaw last failure: ${lastFailure ?? health.lastFailureAt} (${health.lastFailureError ?? "unknown error"})`, + ); + } + + pass(`OpenClaw notification totals: ${health.totalSent} sent, ${health.totalFailed} failed`); } async function checkNotifierConnectivity( @@ -118,7 +187,7 @@ async function sendTestNotifications( ): Promise { const { createPluginRegistry } = await import("@composio/ao-core"); const registry = createPluginRegistry(); - await registry.loadFromConfig(config); + await registry.loadFromConfig(config, importPluginModuleFromSource); const activeNotifierNames = config.defaults?.notifiers ?? []; const configuredNotifiers = Object.keys(config.notifiers ?? {}); diff --git a/packages/cli/src/commands/plugin.ts b/packages/cli/src/commands/plugin.ts index 12bfea551..0c7c8ed58 100644 --- a/packages/cli/src/commands/plugin.ts +++ b/packages/cli/src/commands/plugin.ts @@ -494,7 +494,8 @@ export function registerPlugin(program: Command): void { const config = loadConfig(configPath); const { descriptor, specifier, setupAction } = buildPluginDescriptor(reference, configPath); const verifiedDescriptor = await installOrVerifyPlugin(config, descriptor, specifier); - const nextPlugins = upsertInstalledPlugin(config.plugins ?? [], verifiedDescriptor); + const previousPlugins = config.plugins ?? []; + const nextPlugins = upsertInstalledPlugin(previousPlugins, verifiedDescriptor); writePluginsConfig(configPath, nextPlugins); console.log(chalk.green(formatInstallSummary(verifiedDescriptor, configPath))); @@ -503,7 +504,15 @@ export function registerPlugin(program: Command): void { // Always run setup — interactive in TTY, auto-detect in non-TTY. // Non-interactive setup will auto-detect OpenClaw on localhost if // no --url is given and the gateway is reachable. - await runSetupAction({ url: opts.url, token: opts.token }); + try { + await runSetupAction({ url: opts.url, token: opts.token }); + } catch (err) { + // Rollback: restore previous plugin list so a failed setup + // doesn't leave a half-configured notifier enabled in config. + writePluginsConfig(configPath, previousPlugins); + console.log(chalk.dim("Rolled back plugin config after setup failure.")); + throw err; + } } }); diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index b5384c1fc..31388df8e 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -18,6 +18,7 @@ import { findConfigFile } from "@composio/ao-core"; import { probeGateway, validateToken, + detectOpenClawInstallation, DEFAULT_OPENCLAW_URL, HOOKS_PATH, } from "../lib/openclaw-probe.js"; @@ -40,11 +41,27 @@ interface SetupOptions { url?: string; token?: string; nonInteractive?: boolean; + routingPreset?: OpenClawRoutingPreset; } +type OpenClawRoutingPreset = "urgent-only" | "urgent-action" | "all"; + interface ResolvedConfig { url: string; token: string; + routingPreset: OpenClawRoutingPreset; +} + +const OPENCLAW_ROUTING_PRESETS = { + "urgent-only": ["urgent"], + "urgent-action": ["urgent", "action"], + all: ["urgent", "action", "warning", "info"], +} as const; + +const NOTIFICATION_PRIORITIES = ["urgent", "action", "warning", "info"] as const; + +function isRoutingPreset(value: string | undefined): value is OpenClawRoutingPreset { + return value === "urgent-only" || value === "urgent-action" || value === "all"; } function normalizeOpenClawHooksUrl(url: string): string { @@ -174,7 +191,26 @@ async function interactiveSetup(existingUrl?: string): Promise { } } - return { url, token: tokenValue }; + const routingPreset = await clack.select({ + message: "Which notifications should OpenClaw receive?", + initialValue: "urgent-action", + options: [ + { value: "urgent-action", label: "Urgent + action (recommended)" }, + { value: "urgent-only", label: "Urgent only" }, + { value: "all", label: "All priorities" }, + ], + }); + + if (clack.isCancel(routingPreset)) { + clack.cancel("Setup cancelled."); + throw new SetupAbortedError("Setup cancelled.", 0); + } + + return { + url, + token: tokenValue, + routingPreset: routingPreset as OpenClawRoutingPreset, + }; } // --------------------------------------------------------------------------- @@ -182,14 +218,22 @@ async function interactiveSetup(existingUrl?: string): Promise { // --------------------------------------------------------------------------- async function nonInteractiveSetup(opts: SetupOptions): Promise { - const rawUrl = opts.url ?? process.env["OPENCLAW_GATEWAY_URL"]; + let rawUrl = opts.url ?? process.env["OPENCLAW_GATEWAY_URL"]; const token = opts.token ?? process.env["OPENCLAW_HOOKS_TOKEN"]; + // Auto-detect OpenClaw if no URL was given explicitly if (!rawUrl) { - throw new SetupAbortedError( - "Error: --url is required in non-interactive mode.\n" + - " Example: ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --token YOUR_TOKEN --non-interactive", - ); + const installation = await detectOpenClawInstallation(); + if (installation.state === "running") { + rawUrl = `${installation.gatewayUrl}${HOOKS_PATH}`; + console.log(chalk.dim(`Auto-detected OpenClaw gateway at ${installation.gatewayUrl}`)); + } else { + throw new SetupAbortedError( + "Error: OpenClaw gateway not reachable and no --url provided.\n" + + " Start OpenClaw first, or pass --url explicitly:\n" + + " Example: ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --token YOUR_TOKEN --non-interactive", + ); + } } let url = rawUrl; @@ -209,7 +253,9 @@ async function nonInteractiveSetup(opts: SetupOptions): Promise // token yet. We write both configs first, then the user restarts the gateway. console.log(chalk.dim("Skipping pre-validation (token will be written to both configs).")); - return { url, token: resolvedToken }; + const routingPreset = isRoutingPreset(opts.routingPreset) ? opts.routingPreset : "urgent-action"; + + return { url, token: resolvedToken, routingPreset }; } // --------------------------------------------------------------------------- @@ -252,12 +298,14 @@ function writeOpenClawConfig( rawConfig.defaults.notifiers.push("openclaw"); } - // Add "openclaw" to notificationRouting so notifications actually fire - // (AO prefers per-priority routing over defaults.notifiers) + const defaultsWithoutOpenClaw = (rawConfig.defaults.notifiers as string[]).filter( + (name) => name !== "openclaw", + ); + + // AO routes notifications by priority. Preserve existing notifiers and only + // adjust OpenClaw membership across the standard priority buckets. if (!rawConfig.notificationRouting) { - // Seed from existing defaults.notifiers so we don't silently drop notifiers - // (e.g. desktop) that the user already had for all priorities. - const base = [...new Set([...(rawConfig.defaults.notifiers as string[]), "openclaw"])]; + const base = [...new Set(defaultsWithoutOpenClaw)]; rawConfig.notificationRouting = { urgent: [...base], action: [...base], @@ -265,14 +313,22 @@ function writeOpenClawConfig( info: [...base], }; } else if (typeof rawConfig.notificationRouting === "object") { - for (const priority of Object.keys(rawConfig.notificationRouting)) { - const list = rawConfig.notificationRouting[priority]; - if (Array.isArray(list) && !list.includes("openclaw")) { - list.push("openclaw"); + for (const priority of NOTIFICATION_PRIORITIES) { + if (!Array.isArray(rawConfig.notificationRouting[priority])) { + rawConfig.notificationRouting[priority] = []; } } } + const selectedPriorities = new Set(OPENCLAW_ROUTING_PRESETS[resolved.routingPreset]); + for (const priority of NOTIFICATION_PRIORITIES) { + const list = rawConfig.notificationRouting[priority] as string[]; + rawConfig.notificationRouting[priority] = list.filter((name) => name !== "openclaw"); + if (selectedPriorities.has(priority)) { + rawConfig.notificationRouting[priority].push("openclaw"); + } + } + // Update the document tree from the modified plain object while preserving comments doc.setIn(["notifiers"], rawConfig.notifiers); doc.setIn(["defaults"], rawConfig.defaults); @@ -438,7 +494,11 @@ export function registerSetup(program: Command): void { .description("Connect AO notifications to an OpenClaw gateway") .option("--url ", "OpenClaw webhook URL (e.g. http://127.0.0.1:18789/hooks/agent)") .option("--token ", "OpenClaw hooks auth token") - .option("--non-interactive", "Skip prompts — requires --url (token auto-generated if not provided)") + .option( + "--routing-preset ", + "OpenClaw routing preset: urgent-only | urgent-action | all", + ) + .option("--non-interactive", "Skip prompts — auto-detects OpenClaw if --url not provided (token auto-generated if not provided)") .action(async (opts: SetupOptions) => { try { await runSetupAction(opts); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index a8b4701f3..fd6e8500b 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -41,6 +41,7 @@ import { findWebDir, buildDashboardEnv, waitForPortAndOpen, + openUrl, isPortAvailable, findFreePort, MAX_PORT_SCAN, @@ -58,6 +59,9 @@ import { generateRulesFromTemplates, formatProjectTypeForDisplay, } from "../lib/project-detection.js"; +import { formatCommandError } from "../lib/cli-errors.js"; +import { detectOpenClawInstallation } from "../lib/openclaw-probe.js"; +import { applyOpenClawCredentials } from "../lib/credential-resolver.js"; const DEFAULT_PORT = 3000; const IS_TTY = Boolean(process.stdin.isTTY && process.stdout.isTTY); @@ -162,6 +166,26 @@ function canPromptForInstall(): boolean { return isHumanCaller() && IS_TTY; } +function genericInstallHints(command: string): string[] { + switch (command) { + case "node": + case "npm": + return ["Install Node.js/npm from https://nodejs.org/"]; + case "pnpm": + return [ + "corepack enable && corepack prepare pnpm@latest --activate", + "npm install -g pnpm", + ]; + case "pipx": + return [ + "python3 -m pip install --user pipx", + "python3 -m pipx ensurepath", + ]; + default: + return []; + } +} + /** * Prompt the user to optionally switch orchestrator/worker agents at startup. * Shows only agents detected on the current system (reuses detectAvailableAgents). @@ -254,7 +278,16 @@ function ghInstallAttempts(): InstallAttempt[] { async function runInteractiveCommand(cmd: string, args: string[]): Promise { await new Promise((resolve, reject) => { const child = spawn(cmd, args, { stdio: "inherit" }); - child.once("error", reject); + child.once("error", (err) => { + reject( + formatCommandError(err, { + cmd, + args, + action: "run an interactive installer", + installHints: genericInstallHints(cmd), + }), + ); + }); child.once("close", (code) => { if (code === 0) { resolve(); @@ -747,7 +780,15 @@ async function startDashboard( } child.on("error", (err) => { - console.error(chalk.red("Dashboard failed to start:"), err.message); + const cmd = isDevMode ? "pnpm" : "node"; + const args = isDevMode ? ["run", "dev"] : [resolve(webDir, "dist-server", "start-all.js")]; + const formatted = formatCommandError(err, { + cmd, + args, + action: "start the AO dashboard", + installHints: genericInstallHints(cmd), + }); + console.error(chalk.red("Dashboard failed to start:"), formatted.message); // Emit synthetic exit so callers listening on "exit" can clean up child.emit("exit", 1, null); }); @@ -811,6 +852,43 @@ async function ensureTmux(): Promise { process.exit(1); } +async function warnAboutOpenClawStatus(config: OrchestratorConfig): Promise { + const openclawConfig = config.notifiers?.["openclaw"]; + const openclawConfigured = + openclawConfig !== null && openclawConfig !== undefined && + typeof openclawConfig === "object" && + openclawConfig.plugin === "openclaw"; + const configuredUrl = + openclawConfigured && typeof openclawConfig.url === "string" ? openclawConfig.url : undefined; + + try { + const installation = configuredUrl + ? await detectOpenClawInstallation(configuredUrl) + : await detectOpenClawInstallation(); + + if (openclawConfigured) { + if (installation.state !== "running") { + console.log( + chalk.yellow( + `⚠ OpenClaw is configured but the gateway is not reachable at ${installation.gatewayUrl}. Notifications may fail until it is running.`, + ), + ); + } + return; + } + + if (installation.state === "running") { + console.log( + chalk.yellow( + `⚠ OpenClaw is running at ${installation.gatewayUrl} but AO is not configured to use it. Run \`ao setup openclaw\` if you want OpenClaw notifications.`, + ), + ); + } + } catch { + // OpenClaw probing is advisory for `ao start`; never block startup on it. + } +} + /** * Shared startup logic: launch dashboard + orchestrator session, print summary. * Used by both normal and URL-based start flows. @@ -827,6 +905,21 @@ async function runStartup( if (runtime === "tmux") { await ensureTmux(); } + await warnAboutOpenClawStatus(config); + + // Only inject OpenClaw credentials when the project actually uses OpenClaw. + // This avoids exposing API keys to projects/plugins that don't need them. + const openclawNotifier = config.notifiers?.["openclaw"]; + const hasOpenClaw = + openclawNotifier !== null && openclawNotifier !== undefined && + typeof openclawNotifier === "object" && openclawNotifier.plugin === "openclaw"; + if (hasOpenClaw) { + const injectedKeys = applyOpenClawCredentials(); + if (injectedKeys.length > 0) { + const names = injectedKeys.map((k) => k.key).join(", "); + console.log(chalk.dim(` Resolved from OpenClaw config: ${names}`)); + } + } const sessionId = `${project.sessionPrefix}-orchestrator`; const shouldStartLifecycle = opts?.dashboard !== false || opts?.orchestrator !== false; @@ -1121,11 +1214,7 @@ export function registerStart(program: Command): void { if (choice === "open") { const url = `http://localhost:${running.port}`; - const [cmd, args]: [string, string[]] = - process.platform === "win32" - ? ["cmd.exe", ["/c", "start", "", url]] - : [process.platform === "linux" ? "xdg-open" : "open", [url]]; - spawn(cmd, args, { stdio: "ignore" }); + openUrl(url); process.exit(0); } else if (choice === "new") { // Generate unique orchestrator: same project, new session diff --git a/packages/cli/src/lib/credential-resolver.ts b/packages/cli/src/lib/credential-resolver.ts new file mode 100644 index 000000000..133ab6224 --- /dev/null +++ b/packages/cli/src/lib/credential-resolver.ts @@ -0,0 +1,119 @@ +/** + * Shared credential resolver — resolves API keys from multiple sources. + * + * Resolution order per key: + * 1. Environment variable (already set in process.env) + * 2. OpenClaw config (~/.openclaw/openclaw.json → keys section) + * + * Call `applyOpenClawCredentials()` early in CLI startup so spawned agent + * sessions inherit the resolved values via the parent process environment. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** Keys that AO agents commonly need and OpenClaw may already store. */ +const RESOLVABLE_KEYS = [ + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GITHUB_TOKEN", + "GOOGLE_API_KEY", + "OPENROUTER_API_KEY", +] as const; + +type ResolvableKey = (typeof RESOLVABLE_KEYS)[number]; + +interface ResolvedCredential { + key: ResolvableKey; + value: string; + source: "env" | "openclaw"; +} + +interface AppliedCredential { + key: ResolvableKey; + source: "openclaw"; +} + +function readOpenClawKeys(): Record { + const keys: Record = {}; + + const configPath = join(homedir(), ".openclaw", "openclaw.json"); + if (!existsSync(configPath)) return keys; + + try { + const raw = readFileSync(configPath, "utf-8"); + const config = JSON.parse(raw) as Record; + + // OpenClaw stores keys in a top-level "keys" object: + // { "keys": { "ANTHROPIC_API_KEY": "sk-ant-...", ... } } + const keysSection = config.keys; + if (keysSection && typeof keysSection === "object") { + for (const [k, v] of Object.entries(keysSection as Record)) { + if (typeof v === "string" && v.length > 0) { + keys[k] = v; + } + } + } + + // Also check top-level env / environment block (some OpenClaw setups): + // { "env": { "ANTHROPIC_API_KEY": "sk-ant-..." } } + const envSection = config.env ?? config.environment; + if (envSection && typeof envSection === "object") { + for (const [k, v] of Object.entries(envSection as Record)) { + if (typeof v === "string" && v.length > 0 && !(k in keys)) { + keys[k] = v; + } + } + } + } catch { + // Malformed config — silently skip. + } + + return keys; +} + +/** + * Resolve a single credential from all available sources. + * Returns the value and where it came from, or null if not found anywhere. + */ +export function resolveCredential(key: ResolvableKey): ResolvedCredential | null { + const envValue = process.env[key]; + if (envValue && envValue.length > 0) { + return { key, value: envValue, source: "env" }; + } + + const openclawKeys = readOpenClawKeys(); + const openclawValue = openclawKeys[key]; + if (openclawValue) { + return { key, value: openclawValue, source: "openclaw" }; + } + + return null; +} + +/** + * Populate `process.env` with API keys found in OpenClaw config that are + * not already set in the environment. This makes them available to all + * spawned agent sessions (tmux inherits the parent env). + * + * Returns the list of keys that were injected from OpenClaw. + */ +export function applyOpenClawCredentials(): AppliedCredential[] { + const openclawKeys = readOpenClawKeys(); + const applied: AppliedCredential[] = []; + + for (const key of RESOLVABLE_KEYS) { + if (process.env[key] && process.env[key]!.length > 0) continue; + + const value = openclawKeys[key]; + if (value) { + process.env[key] = value; + applied.push({ key, source: "openclaw" }); + } + } + + return applied; +} + +export { RESOLVABLE_KEYS, type ResolvableKey, type ResolvedCredential, type AppliedCredential }; diff --git a/packages/cli/src/lib/openclaw-probe.ts b/packages/cli/src/lib/openclaw-probe.ts index 06db7a282..6d877e913 100644 --- a/packages/cli/src/lib/openclaw-probe.ts +++ b/packages/cli/src/lib/openclaw-probe.ts @@ -5,6 +5,11 @@ * Probes the OpenClaw gateway for reachability and validates auth tokens. */ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + export interface ProbeResult { reachable: boolean; httpStatus?: number; @@ -16,10 +21,37 @@ export interface TokenValidation { error?: string; } +export interface OpenClawInstallation { + state: "missing" | "installed-but-stopped" | "running"; + gatewayUrl: string; + binaryPath?: string; + configPath?: string; + probe: ProbeResult; +} + const DEFAULT_TIMEOUT_MS = 3_000; const DEFAULT_OPENCLAW_URL = "http://127.0.0.1:18789"; const HOOKS_PATH = "/hooks/agent"; +function normalizeGatewayBaseUrl(url: string): string { + const normalized = url.trim().replace(/\/+$/, ""); + return normalized.endsWith(HOOKS_PATH) + ? normalized.slice(0, -HOOKS_PATH.length) + : normalized; +} + +function resolveOpenClawBinaryPath(): string | undefined { + const shell = process.env["SHELL"] ?? "/bin/sh"; + if (!shell.startsWith("/")) return undefined; + const result = spawnSync(shell, ["-lc", "command -v openclaw"], { + encoding: "utf-8", + }); + + if (result.status !== 0) return undefined; + const path = result.stdout.trim(); + return path || undefined; +} + /** * Probe the OpenClaw gateway for reachability. * Sends a GET to the base URL (not /hooks/agent) with a short timeout. @@ -28,7 +60,7 @@ export async function probeGateway( baseUrl: string = DEFAULT_OPENCLAW_URL, timeoutMs: number = DEFAULT_TIMEOUT_MS, ): Promise { - const url = baseUrl.replace(/\/+$/, ""); + const url = normalizeGatewayBaseUrl(baseUrl); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { @@ -54,6 +86,29 @@ export async function probeGateway( } } +export async function detectOpenClawInstallation( + baseUrl: string = DEFAULT_OPENCLAW_URL, + timeoutMs: number = DEFAULT_TIMEOUT_MS, +): Promise { + const gatewayUrl = normalizeGatewayBaseUrl(baseUrl); + const binaryPath = resolveOpenClawBinaryPath(); + const configPath = join(homedir(), ".openclaw", "openclaw.json"); + const hasConfig = existsSync(configPath); + const probe = await probeGateway(gatewayUrl, timeoutMs); + + return { + state: probe.reachable + ? "running" + : binaryPath || hasConfig + ? "installed-but-stopped" + : "missing", + gatewayUrl, + binaryPath, + configPath: hasConfig ? configPath : undefined, + probe, + }; +} + /** * Validate an auth token against the OpenClaw hooks endpoint. * Sends a lightweight test payload and checks the response. @@ -125,4 +180,4 @@ export async function validateToken( } } -export { DEFAULT_OPENCLAW_URL, HOOKS_PATH }; +export { DEFAULT_OPENCLAW_URL, HOOKS_PATH, normalizeGatewayBaseUrl }; diff --git a/packages/cli/src/lib/plugin-marketplace.ts b/packages/cli/src/lib/plugin-marketplace.ts index 2aa49df1e..82a0095f5 100644 --- a/packages/cli/src/lib/plugin-marketplace.ts +++ b/packages/cli/src/lib/plugin-marketplace.ts @@ -132,7 +132,8 @@ export function isLocalPluginReference(reference: string): boolean { reference.startsWith("./") || reference.startsWith("../") || reference.startsWith("/") || - reference.startsWith("~/") + reference.startsWith("~/") || + isAbsolute(reference) ); } diff --git a/packages/plugins/notifier-openclaw/src/index.test.ts b/packages/plugins/notifier-openclaw/src/index.test.ts index d436db249..871374c4f 100644 --- a/packages/plugins/notifier-openclaw/src/index.test.ts +++ b/packages/plugins/notifier-openclaw/src/index.test.ts @@ -1,4 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import type { NotifyAction, OrchestratorEvent } from "@composio/ao-core"; import { create, manifest } from "./index.js"; @@ -17,14 +20,23 @@ function makeEvent(overrides: Partial = {}): OrchestratorEven } describe("notifier-openclaw", () => { + let tempConfigDir: string; + let tempConfigPath: string; + let tempHealthPath: string; + beforeEach(() => { vi.restoreAllMocks(); delete process.env.OPENCLAW_HOOKS_TOKEN; + tempConfigDir = mkdtempSync(join(tmpdir(), "openclaw-notifier-test-")); + tempConfigPath = join(tempConfigDir, "agent-orchestrator.yaml"); + tempHealthPath = join(tempConfigDir, "openclaw-health.json"); + writeFileSync(tempConfigPath, "projects: {}\n"); }); afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); + rmSync(tempConfigDir, { recursive: true, force: true }); }); it("has correct manifest", () => { @@ -182,4 +194,51 @@ describe("notifier-openclaw", () => { "Can't reach OpenClaw gateway", ); }); + + it("records success telemetry when a notification is sent", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + token: "tok", + configPath: tempConfigPath, + healthSummaryPath: tempHealthPath, + }); + await notifier.notify(makeEvent()); + + expect(existsSync(tempHealthPath)).toBe(true); + + const summary = JSON.parse(readFileSync(tempHealthPath, "utf-8")) as { + lastSuccessAt: string | null; + totalSent: number; + totalFailed: number; + }; + expect(summary.lastSuccessAt).toBeTruthy(); + expect(summary.totalSent).toBe(1); + expect(summary.totalFailed).toBe(0); + }); + + it("records failure telemetry when notification delivery fails", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("fetch failed: ECONNREFUSED")); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + token: "tok", + retries: 0, + configPath: tempConfigPath, + healthSummaryPath: tempHealthPath, + }); + await expect(notifier.notify(makeEvent())).rejects.toThrow(); + + const summary = JSON.parse(readFileSync(tempHealthPath, "utf-8")) as { + lastFailureAt: string | null; + lastFailureError: string | null; + totalSent: number; + totalFailed: number; + }; + expect(summary.lastFailureAt).toBeTruthy(); + expect(summary.lastFailureError).toContain("Can't reach OpenClaw gateway"); + expect(summary.totalSent).toBe(0); + expect(summary.totalFailed).toBe(1); + }); }); diff --git a/packages/plugins/notifier-openclaw/src/index.ts b/packages/plugins/notifier-openclaw/src/index.ts index 840c6c85c..0b5c39aea 100644 --- a/packages/plugins/notifier-openclaw/src/index.ts +++ b/packages/plugins/notifier-openclaw/src/index.ts @@ -1,6 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { type EventPriority, type Notifier, @@ -8,6 +8,7 @@ import { type NotifyContext, type OrchestratorEvent, type PluginModule, + getObservabilityBaseDir, } from "@composio/ao-core"; import { isRetryableHttpStatus, normalizeRetryConfig, validateUrl } from "@composio/ao-core/utils"; @@ -49,6 +50,73 @@ interface OpenClawWebhookPayload { deliver?: boolean; } +interface OpenClawHealthSummary { + lastSuccessAt: string | null; + lastFailureAt: string | null; + lastFailureError: string | null; + totalSent: number; + totalFailed: number; +} + +const DEFAULT_HEALTH_SUMMARY: OpenClawHealthSummary = { + lastSuccessAt: null, + lastFailureAt: null, + lastFailureError: null, + totalSent: 0, + totalFailed: 0, +}; + +function readHealthSummary(path: string): OpenClawHealthSummary { + try { + if (!existsSync(path)) return { ...DEFAULT_HEALTH_SUMMARY }; + const raw = readFileSync(path, "utf-8"); + return { + ...DEFAULT_HEALTH_SUMMARY, + ...(JSON.parse(raw) as Partial), + }; + } catch { + return { ...DEFAULT_HEALTH_SUMMARY }; + } +} + +function writeHealthSummary(path: string, summary: OpenClawHealthSummary): void { + try { + mkdirSync(dirname(path), { recursive: true }); + const tempPath = `${path}.tmp.${process.pid}`; + writeFileSync(tempPath, JSON.stringify(summary, null, 2) + "\n"); + renameSync(tempPath, path); + } catch { + // Health telemetry is best-effort and must never block notifications. + } +} + +function getHealthSummaryPath(config?: Record): string | null { + const explicitPath = + typeof config?.healthSummaryPath === "string" ? config.healthSummaryPath : undefined; + if (explicitPath) return explicitPath; + + const configPath = typeof config?.configPath === "string" ? config.configPath : undefined; + if (!configPath) return null; + return join(getObservabilityBaseDir(configPath), "openclaw-health.json"); +} + +function recordHealthSuccess(path: string | null): void { + if (!path) return; + const summary = readHealthSummary(path); + summary.lastSuccessAt = new Date().toISOString(); + summary.totalSent += 1; + writeHealthSummary(path, summary); +} + +function recordHealthFailure(path: string | null, error: unknown): void { + if (!path) return; + const summary = readHealthSummary(path); + summary.lastFailureAt = new Date().toISOString(); + summary.lastFailureError = error instanceof Error ? error.message : String(error); + summary.totalFailed += 1; + writeHealthSummary(path, summary); +} + async function postWithRetry( url: string, payload: OpenClawWebhookPayload, @@ -181,6 +249,7 @@ export function create(config?: Record): Notifier { typeof config?.sessionKeyPrefix === "string" ? config.sessionKeyPrefix : "hook:ao:"; const wakeMode: WakeMode = config?.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now"; const deliver = typeof config?.deliver === "boolean" ? config.deliver : true; + const healthSummaryPath = getHealthSummaryPath(config); const { retries, retryDelayMs } = normalizeRetryConfig(config); @@ -201,8 +270,13 @@ export function create(config?: Record): Notifier { if (token) headers["Authorization"] = `Bearer ${token}`; const sessionId = payload.sessionKey?.slice(sessionKeyPrefix.length) ?? "default"; - - await postWithRetry(url, payload, headers, retries, retryDelayMs, { sessionId }); + try { + await postWithRetry(url, payload, headers, retries, retryDelayMs, { sessionId }); + recordHealthSuccess(healthSummaryPath); + } catch (err) { + recordHealthFailure(healthSummaryPath, err); + throw err; + } } return {