Merge pull request #832 from ComposioHQ/feat/openclaw-dx
Feat/openclaw dx
This commit is contained in:
commit
f6dc8181df
|
|
@ -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<T = unknown> {
|
||||
manifest: PluginManifest;
|
||||
create(config?: Record<string, unknown>): unknown;
|
||||
detect?(input?: unknown): Promise<unknown> | unknown;
|
||||
create(config?: Record<string, unknown>): T;
|
||||
detect?(): boolean;
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string[]>;
|
||||
};
|
||||
|
||||
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<string, string[]>;
|
||||
};
|
||||
|
||||
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(() => {
|
||||
|
|
|
|||
|
|
@ -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 <url>` triggers handleUrlStart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<void> {
|
||||
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 ?? {});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ResolvedConfig> {
|
|||
}
|
||||
}
|
||||
|
||||
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<ResolvedConfig> {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function nonInteractiveSetup(opts: SetupOptions): Promise<ResolvedConfig> {
|
||||
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<ResolvedConfig>
|
|||
// 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 <url>", "OpenClaw webhook URL (e.g. http://127.0.0.1:18789/hooks/agent)")
|
||||
.option("--token <token>", "OpenClaw hooks auth token")
|
||||
.option("--non-interactive", "Skip prompts — requires --url (token auto-generated if not provided)")
|
||||
.option(
|
||||
"--routing-preset <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);
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
await new Promise<void>((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<void> {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
async function warnAboutOpenClawStatus(config: OrchestratorConfig): Promise<void> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<string, string> {
|
||||
const keys: Record<string, string> = {};
|
||||
|
||||
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<string, unknown>;
|
||||
|
||||
// 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<string, unknown>)) {
|
||||
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<string, unknown>)) {
|
||||
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 };
|
||||
|
|
@ -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<ProbeResult> {
|
||||
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<OpenClawInstallation> {
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -132,7 +132,8 @@ export function isLocalPluginReference(reference: string): boolean {
|
|||
reference.startsWith("./") ||
|
||||
reference.startsWith("../") ||
|
||||
reference.startsWith("/") ||
|
||||
reference.startsWith("~/")
|
||||
reference.startsWith("~/") ||
|
||||
isAbsolute(reference)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<OrchestratorEvent> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<OpenClawHealthSummary>),
|
||||
};
|
||||
} 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, unknown>): 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<string, unknown>): 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<string, unknown>): 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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue