Merge pull request #832 from ComposioHQ/feat/openclaw-dx

Feat/openclaw dx
This commit is contained in:
Dhruv Sharma 2026-03-31 20:57:36 +05:30 committed by GitHub
commit f6dc8181df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 787 additions and 57 deletions

View File

@ -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;
}
```

View File

@ -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(),
}));

View File

@ -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(() => {

View File

@ -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
// ---------------------------------------------------------------------------

View File

@ -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", () => {

View File

@ -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 ?? {});

View File

@ -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;
}
}
});

View File

@ -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);

View File

@ -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

View File

@ -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 };

View File

@ -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 };

View File

@ -132,7 +132,8 @@ export function isLocalPluginReference(reference: string): boolean {
reference.startsWith("./") ||
reference.startsWith("../") ||
reference.startsWith("/") ||
reference.startsWith("~/")
reference.startsWith("~/") ||
isAbsolute(reference)
);
}

View File

@ -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);
});
});

View File

@ -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 {