From 570f3b588f44feef6fffc60dc8fecb542d8a6fb9 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Thu, 26 Mar 2026 21:58:19 +0530 Subject: [PATCH] fix: address CodeRabbit review comments - Use consistent regex for OPENCLAW_HOOKS_TOKEN detection and replacement in shell profile (prevents silent no-ops for non-exported lines) - Broaden token detection regex to match lines with/without export prefix and leading whitespace - Fix misleading --non-interactive help text (token is auto-generated) - Fix doctor.ts catch block to say "Notifier checks failed" not "load config" - Fix 204 mock in Discord notifier test (ok: true, not ok: false) - Fix weak no-duplicate assertion in setup.test.ts (actually count list items) - Add discord to notifier options comment in config-instruction.ts - URL-encode threadId in Discord webhook URL construction - Add aoCwd to required[] in openclaw.plugin.json configSchema - Add HTTPS recommendation comment to agent-orchestrator.yaml.example - Add rimraf for cross-platform clean script in notifier-discord - Rename "Recommended Settings" to "Required: Disable Conflicting Built-in Skills" with explicit warning in docs - Add /ao setup post-setup reminder to manually disable coding-agent skill - Fix misleading README non-interactive example wording Co-Authored-By: Claude Sonnet 4.6 --- agent-orchestrator.yaml.example | 2 +- docs/openclaw-plugin-setup.md | 15 + openclaw-plugin/index.test.ts | 124 ++++ openclaw-plugin/index.ts | 570 +++++++++++++++--- openclaw-plugin/openclaw.plugin.json | 1 + packages/cli/__tests__/commands/setup.test.ts | 228 +++++-- packages/cli/src/commands/doctor.ts | 2 +- packages/cli/src/commands/setup.ts | 227 ++++--- packages/cli/src/lib/config-instruction.ts | 2 +- .../plugins/notifier-discord/package.json | 3 +- .../notifier-discord/src/index.test.ts | 2 +- .../plugins/notifier-discord/src/index.ts | 2 +- packages/plugins/notifier-openclaw/README.md | 2 +- .../plugins/notifier-openclaw/src/index.ts | 51 +- 14 files changed, 981 insertions(+), 250 deletions(-) create mode 100644 openclaw-plugin/index.test.ts diff --git a/agent-orchestrator.yaml.example b/agent-orchestrator.yaml.example index 4b7baf8d4..f2607c3e1 100644 --- a/agent-orchestrator.yaml.example +++ b/agent-orchestrator.yaml.example @@ -102,7 +102,7 @@ projects: # # openclaw: # plugin: openclaw -# url: http://127.0.0.1:18789/hooks/agent +# url: http://127.0.0.1:18789/hooks/agent # Use https:// for remote (non-localhost) deployments # token: ${OPENCLAW_HOOKS_TOKEN} # retries: 3 # retryDelayMs: 1000 diff --git a/docs/openclaw-plugin-setup.md b/docs/openclaw-plugin-setup.md index d09c63171..08010712a 100644 --- a/docs/openclaw-plugin-setup.md +++ b/docs/openclaw-plugin-setup.md @@ -52,6 +52,21 @@ openclaw config set tools.allow '["group:plugins"]' openclaw config set plugins.allow '["agent-orchestrator"]' ``` +### Required: Disable Conflicting Built-in Skills + +**Without these, the bot may ignore AO and write code directly.** Run once after setup: + +```bash +# Prevent the bot from writing code directly — it should delegate to AO instead +openclaw config set tools.deny '["exec", "write", "str_replace_based_edit_tool", "create_file", "str_replace_editor"]' + +# Disable the built-in coding skill (it tells the bot to use Codex/Claude Code directly, overriding AO) +openclaw config set skills.entries.coding-agent.enabled false + +# Disable the built-in GitHub issues skill (it spawns OpenClaw sub-agents, bypassing AO) +openclaw config set skills.entries.gh-issues.enabled false +``` + ### Optional Settings ```bash diff --git a/openclaw-plugin/index.test.ts b/openclaw-plugin/index.test.ts new file mode 100644 index 000000000..da777c362 --- /dev/null +++ b/openclaw-plugin/index.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + extractConfiguredReposFromYaml, + fetchIssues, + mergeStringLists, + parseStringArraySetting, +} from "./index.ts"; + +function makeIssue(number: number, title: string, repo: string) { + return { + number, + title, + labels: [], + state: "open", + assignees: [], + createdAt: `2026-03-${String(number).padStart(2, "0")}T00:00:00Z`, + url: `https://github.com/${repo}/issues/${number}`, + }; +} + +test("extractConfiguredReposFromYaml reads every project repo", () => { + const rawYaml = ` +port: 3000 +projects: + app: + repo: acme/app + path: ~/code/app + docs: + repo: "acme/docs" # keep quoted repos working + path: ~/code/docs +notifiers: + openclaw: + plugin: openclaw +`; + + assert.deepEqual(extractConfiguredReposFromYaml(rawYaml), ["acme/app", "acme/docs"]); +}); + +test("fetchIssues queries every configured repo when repo is omitted", () => { + const ghCalls: string[] = []; + const result = fetchIssues( + { aoCwd: "/tmp/work" }, + {}, + { + getConfiguredRepos: () => ["acme/app", "acme/docs"], + runGh: (_config, args) => { + const repoIndex = args.indexOf("-R"); + const repo = repoIndex >= 0 ? args[repoIndex + 1] : "default"; + ghCalls.push(repo); + + if (repo === "acme/app") { + return { ok: true, output: JSON.stringify([makeIssue(1, "App bug", repo)]) }; + } + if (repo === "acme/docs") { + return { ok: true, output: JSON.stringify([makeIssue(2, "Docs bug", repo)]) }; + } + + return { ok: false, error: `unexpected repo: ${repo}` }; + }, + }, + ); + + assert.deepEqual(ghCalls, ["acme/app", "acme/docs"]); + assert.equal(result.ok, true); + if (!result.ok) return; + + assert.deepEqual(result.scannedRepos, ["acme/app", "acme/docs"]); + assert.equal(result.warnings.length, 0); + assert.deepEqual( + result.issues.map((issue) => issue.repository), + ["acme/docs", "acme/app"], + ); +}); + +test("fetchIssues surfaces GitHub failures instead of reporting an empty board", () => { + const result = fetchIssues( + { aoCwd: "/tmp/work" }, + {}, + { + getConfiguredRepos: () => ["acme/app"], + runGh: () => ({ ok: false, error: "gh auth token missing" }), + }, + ); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.match(result.error, /gh auth token missing/); +}); + +test("fetchIssues keeps partial failures visible when at least one repo succeeds", () => { + const result = fetchIssues( + { aoCwd: "/tmp/work" }, + {}, + { + getConfiguredRepos: () => ["acme/app", "acme/docs"], + runGh: (_config, args) => { + const repoIndex = args.indexOf("-R"); + const repo = repoIndex >= 0 ? args[repoIndex + 1] : "default"; + if (repo === "acme/app") { + return { ok: true, output: JSON.stringify([makeIssue(3, "App bug", repo)]) }; + } + return { ok: false, error: "gh not authenticated for docs repo" }; + }, + }, + ); + + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.issues.length, 1); + assert.deepEqual(result.warnings, ["acme/docs: gh not authenticated for docs repo"]); +}); + +test("allowlist helpers preserve existing entries while adding AO requirements", () => { + assert.deepEqual(mergeStringLists(["custom:tools", "group:plugins"], ["group:plugins"]), [ + "custom:tools", + "group:plugins", + ]); + assert.deepEqual(parseStringArraySetting('["group:plugins","custom:tools"]'), [ + "group:plugins", + "custom:tools", + ]); + assert.deepEqual(parseStringArraySetting("null"), []); +}); diff --git a/openclaw-plugin/index.ts b/openclaw-plugin/index.ts index bf21d4649..376ac001e 100644 --- a/openclaw-plugin/index.ts +++ b/openclaw-plugin/index.ts @@ -15,6 +15,9 @@ */ import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; // --------------------------------------------------------------------------- // Types @@ -79,32 +82,336 @@ interface GitHubIssue { assignees: Array<{ login: string }>; createdAt: string; url: string; + repository?: string; } -function fetchIssues(config: PluginConfig, repo?: string): GitHubIssue[] { - const args = ["issue", "list"]; - if (repo) args.push("-R", repo); - args.push("--state", "open", "--json", "number,title,labels,state,assignees,createdAt,url", "--limit", "30"); - const result = tryRunGh( - config, - args, - 15_000, - ); - if (!result.ok) return []; +interface FetchIssuesSuccess { + ok: true; + issues: GitHubIssue[]; + scannedRepos: string[]; + warnings: string[]; +} + +interface FetchIssuesFailure { + ok: false; + error: string; +} + +type FetchIssuesResult = FetchIssuesSuccess | FetchIssuesFailure; + +interface FetchIssuesOptions { + repo?: string; + labels?: string; +} + +interface FetchIssuesDeps { + getConfiguredRepos: (config: PluginConfig) => string[]; + runGh: typeof tryRunGh; +} + +function resolveAoConfigPath(config: PluginConfig): string | null { + const candidates: string[] = []; + const envPath = process.env.AO_CONFIG_PATH; + if (envPath) candidates.push(resolve(envPath)); + + let currentDir = resolve(config.aoCwd || process.cwd()); + while (true) { + candidates.push( + join(currentDir, "agent-orchestrator.yaml"), + join(currentDir, "agent-orchestrator.yml"), + ); + const parentDir = dirname(currentDir); + if (parentDir === currentDir) break; + currentDir = parentDir; + } + + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + + return null; +} + +function stripYamlInlineComment(value: string): string { + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let i = 0; i < value.length; i++) { + const char = value[i]; + if (char === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + if (char === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } + if (char === "#" && !inSingleQuote && !inDoubleQuote) { + return value.slice(0, i).trim(); + } + } + + return value.trim(); +} + +function normalizeYamlScalar(value: string): string { + const stripped = stripYamlInlineComment(value); + if (!stripped) return ""; + + if ( + (stripped.startsWith('"') && stripped.endsWith('"')) || + (stripped.startsWith("'") && stripped.endsWith("'")) + ) { + return stripped.slice(1, -1).trim(); + } + + return stripped; +} + +export function extractConfiguredReposFromYaml(rawYaml: string): string[] { + const repos = new Set(); + const lines = rawYaml.split(/\r?\n/); + let inProjects = false; + // Detected at runtime from the first project entry line — not hardcoded. + let projectKeyIndent: number | null = null; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const indent = line.match(/^ */)?.[0].length ?? 0; + + if (!inProjects) { + if (trimmed === "projects:" && indent === 0) { + inProjects = true; + } + continue; + } + + // Any top-level key after projects: ends the block. + if (indent === 0) break; + + // Detect the indentation level of project name keys from the first entry. + if (projectKeyIndent === null) { + if (trimmed.endsWith(":")) projectKeyIndent = indent; + continue; + } + + // Lines at the project-key indent are project names — skip them. + if (indent === projectKeyIndent) continue; + + // Lines indented deeper than the project key are project properties. + if (indent > projectKeyIndent) { + const match = trimmed.match(/^repo:\s*(.+)$/); + if (!match) continue; + const repo = normalizeYamlScalar(match[1]); + if (repo) repos.add(repo); + } + } + + return [...repos]; +} + +function getConfiguredRepos(config: PluginConfig): string[] { + const configPath = resolveAoConfigPath(config); + if (!configPath) return []; + try { - return JSON.parse(result.output) as GitHubIssue[]; + const rawYaml = readFileSync(configPath, "utf-8"); + return extractConfiguredReposFromYaml(rawYaml); } catch { return []; } } +function getIssueRepository(issue: GitHubIssue): string | null { + if (issue.repository) return issue.repository; + const match = issue.url.match(/github\.com\/([^/]+\/[^/]+)\/issues\//); + return match?.[1] ?? null; +} + +function getIssueIdentity(issue: GitHubIssue): string { + return issue.url || `${getIssueRepository(issue) ?? "default"}#${issue.number}`; +} + +function formatIssueWarnings(warnings: string[]): string { + return warnings.map((warning) => `- ${warning}`).join("\n"); +} + +export function mergeStringLists(existing: string[], required: string[]): string[] { + const merged = [...existing]; + for (const value of required) { + if (!merged.includes(value)) merged.push(value); + } + return merged; +} + +export function parseStringArraySetting(output: string): string[] | null { + const trimmed = output.trim(); + if (!trimmed) return []; + if (trimmed === "null" || trimmed === "undefined") return []; + + try { + const parsed = JSON.parse(trimmed); + if (parsed == null) return []; + if (Array.isArray(parsed)) { + return parsed.filter((value): value is string => typeof value === "string"); + } + if (typeof parsed === "string") { + return parsed ? [parsed] : []; + } + } catch { + // Fall through to plain-text parsing + } + + if (trimmed.includes("\n")) { + return trimmed + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + } + + if (trimmed.includes(",")) { + return trimmed + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + } + + return [trimmed]; +} + +function getNestedValue(root: unknown, path: string[]): unknown { + let current = root; + for (const segment of path) { + if (!current || typeof current !== "object" || Array.isArray(current)) { + return undefined; + } + current = (current as Record)[segment]; + } + return current; +} + +function readOpenClawConfig(): Record | null { + try { + const configPath = join(homedir(), ".openclaw", "openclaw.json"); + if (!existsSync(configPath)) return {}; + return JSON.parse(readFileSync(configPath, "utf-8")) as Record; + } catch { + return null; + } +} + +function readOpenClawStringArraySetting(setting: string, path: string[]): string[] { + const cliResult = tryRun("openclaw", ["config", "get", setting], 5_000); + if (cliResult.ok) { + const parsed = parseStringArraySetting(cliResult.output); + if (parsed) return parsed; + } + + const openClawConfig = readOpenClawConfig(); + if (!openClawConfig) return []; + + const nestedValue = getNestedValue(openClawConfig, path); + if (Array.isArray(nestedValue)) { + return nestedValue.filter((value): value is string => typeof value === "string"); + } + if (typeof nestedValue === "string" && nestedValue) { + return [nestedValue]; + } + + return []; +} + +export function fetchIssues( + config: PluginConfig, + options: FetchIssuesOptions = {}, + deps: FetchIssuesDeps = { + getConfiguredRepos, + runGh: tryRunGh, + }, +): FetchIssuesResult { + const repos = options.repo ? [options.repo] : deps.getConfiguredRepos(config); + const targets = repos.length > 0 ? repos : [undefined]; + const issues: GitHubIssue[] = []; + const warnings: string[] = []; + const scannedRepos: string[] = []; + + for (const targetRepo of targets) { + const args = ["issue", "list"]; + const repoLabel = targetRepo ?? "default repo"; + if (targetRepo) args.push("-R", targetRepo); + if (options.labels) args.push("--label", options.labels); + args.push( + "--state", + "open", + "--json", + "number,title,labels,state,assignees,createdAt,url", + "--limit", + "30", + ); + + const result = deps.runGh(config, args, 15_000); + if (!result.ok) { + warnings.push(`${repoLabel}: ${result.error}`); + continue; + } + + try { + const parsed = JSON.parse(result.output) as GitHubIssue[]; + for (const issue of parsed) { + issue.repository = targetRepo ?? getIssueRepository(issue) ?? undefined; + issues.push(issue); + } + if (targetRepo) scannedRepos.push(targetRepo); + } catch { + warnings.push(`${repoLabel}: failed to parse GitHub CLI output`); + } + } + + const dedupedIssues = issues + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .filter( + (issue, index, allIssues) => + allIssues.findIndex( + (candidate) => getIssueIdentity(candidate) === getIssueIdentity(issue), + ) === index, + ); + const inferredRepos = dedupedIssues + .map((issue) => getIssueRepository(issue)) + .filter((repo): repo is string => Boolean(repo)); + + if (warnings.length > 0 && dedupedIssues.length === 0) { + return { + ok: false, + error: `GitHub issue query failed:\n${formatIssueWarnings(warnings)}`, + }; + } + + return { + ok: true, + issues: dedupedIssues, + scannedRepos: [ + ...new Set( + scannedRepos.length > 0 ? scannedRepos : inferredRepos.length > 0 ? inferredRepos : repos, + ), + ], + warnings, + }; +} + function formatIssueList(issues: GitHubIssue[]): string { if (issues.length === 0) return "No open issues found."; + const repoLabels = new Set(issues.map((issue) => getIssueRepository(issue)).filter(Boolean)); + const includeRepository = repoLabels.size > 1; return issues .map((issue, i) => { const labels = issue.labels.map((l) => l.name).join(", "); const labelStr = labels ? ` [${labels}]` : ""; - return `${i + 1}. #${issue.number} — ${issue.title}${labelStr}`; + const repoPrefix = includeRepository + ? `${getIssueRepository(issue) ?? issue.repository ?? "unknown"}#${issue.number}` + : `#${issue.number}`; + return `${i + 1}. ${repoPrefix} — ${issue.title}${labelStr}`; }) .join("\n"); } @@ -142,17 +449,40 @@ async function spawnWithRetry( // --------------------------------------------------------------------------- const WORK_TRIGGERS = [ - "what needs", "what should i", "what do i need", - "start working", "morning", "let's go", "lets go", - "what's going on", "whats going on", "status update", - "check my repos", "check my issues", "check issues", - "any issues", "what's on the board", "whats on the board", - "what can i work on", "what to work on", "work on today", - "what's open", "whats open", "open issues", - "scan my repos", "scan repos", "scan issues", - "engineering update", "dev update", "project update", - "anything to do", "what's pending", "whats pending", - "ready to work", "what's the plan", "whats the plan", + "what needs", + "what should i", + "what do i need", + "start working", + "morning", + "let's go", + "lets go", + "what's going on", + "whats going on", + "status update", + "check my repos", + "check my issues", + "check issues", + "any issues", + "what's on the board", + "whats on the board", + "what can i work on", + "what to work on", + "work on today", + "what's open", + "whats open", + "open issues", + "scan my repos", + "scan repos", + "scan issues", + "engineering update", + "dev update", + "project update", + "anything to do", + "what's pending", + "whats pending", + "ready to work", + "what's the plan", + "whats the plan", ]; function isWorkRelated(message: string): boolean { @@ -179,13 +509,22 @@ export default function (api: any) { /** Build a live-data context block from AO + GitHub */ function buildLiveContext(): string | null { try { - const issues = fetchIssues(config); + const issuesResult = fetchIssues(config); const sessionsResult = tryRunAo(config, ["status"], 10_000); - const issuesSummary = - issues.length > 0 - ? `Open issues (${issues.length}):\n${formatIssueList(issues)}` - : "No open issues across your repos."; + const issuesSummary = !issuesResult.ok + ? issuesResult.error + : issuesResult.issues.length > 0 + ? [ + `Open issues (${issuesResult.issues.length}${issuesResult.scannedRepos.length > 1 ? ` across ${issuesResult.scannedRepos.length} repos` : ""}):`, + formatIssueList(issuesResult.issues), + issuesResult.warnings.length > 0 + ? `GitHub warnings:\n${formatIssueWarnings(issuesResult.warnings)}` + : null, + ] + .filter(Boolean) + .join("\n") + : `No open issues found${issuesResult.scannedRepos.length > 1 ? ` across ${issuesResult.scannedRepos.length} repos` : ""}.`; const sessionsSummary = sessionsResult.ok ? `Active sessions:\n${sessionsResult.output}` @@ -228,11 +567,7 @@ export default function (api: any) { // Hook 1: message_received — detect work-related inbound messages const onMessageReceived = async (event: any) => { const message = - event?.message?.text || - event?.message?.content || - event?.text || - event?.content || - ""; + event?.message?.text || event?.message?.content || event?.text || event?.content || ""; if (isWorkRelated(message)) { cleanStalePending(); @@ -249,9 +584,9 @@ export default function (api: any) { // Not a command — just context so the model can make an informed choice. const routingContext = [ "[Agent Orchestrator] This project has AO installed — an open-source orchestrator " + - "for durable coding agents (Claude Code, Codex, OpenCode). ao_spawn creates an " + - "isolated git worktree, starts an agent, and wires up feedback loops so PR reviews " + - "and CI failures automatically route to the right agent.", + "for durable coding agents (Claude Code, Codex, OpenCode). ao_spawn creates an " + + "isolated git worktree, starts an agent, and wires up feedback loops so PR reviews " + + "and CI failures automatically route to the right agent.", ]; // If this is a work-related message, also inject live repo data @@ -336,21 +671,34 @@ export default function (api: any) { case "spawn": { if (!rest) return { text: "Usage: /ao spawn " }; const issueArg = sanitizeArg(rest.split(/\s+/)[0]); - if (!isValidIssueId(issueArg)) return { text: `Invalid issue identifier: ${issueArg}. Expected a number like 42 or #42.` }; + if (!isValidIssueId(issueArg)) + return { + text: `Invalid issue identifier: ${issueArg}. Expected a number like 42 or #42.`, + }; const result = await spawnWithRetry(config, ["spawn", issueArg]); if (!result.ok) return { text: `Failed to spawn:\n${result.error}` }; return { text: result.output }; } case "issues": { - const issues = fetchIssues(config, rest || undefined); - return { text: formatIssueList(issues) }; + const issuesResult = fetchIssues(config, { repo: rest || undefined }); + if (!issuesResult.ok) return { text: issuesResult.error }; + + const lines = [formatIssueList(issuesResult.issues)]; + if (issuesResult.warnings.length > 0) { + lines.push(""); + lines.push("GitHub warnings:"); + lines.push(formatIssueWarnings(issuesResult.warnings)); + } + + return { text: lines.join("\n") }; } case "batch-spawn": { if (!rest) return { text: "Usage: /ao batch-spawn ..." }; const issueArgs = rest.split(/\s+/).map(sanitizeArg); - if (!issueArgs.every(isValidIssueId)) return { text: `Invalid issue identifiers. Expected numbers like: 42 43 44` }; + if (!issueArgs.every(isValidIssueId)) + return { text: `Invalid issue identifiers. Expected numbers like: 42 43 44` }; const result = tryRunAo(config, ["batch-spawn", ...issueArgs], 60_000); if (!result.ok) return { text: `Failed to batch-spawn:\n${result.error}` }; return { text: result.output }; @@ -359,7 +707,8 @@ export default function (api: any) { case "retry": { if (!rest) return { text: "Usage: /ao retry " }; const sessionId = sanitizeArg(rest.trim()); - if (!isValidSessionId(sessionId)) return { text: `Invalid session ID: ${rest}. Expected format like ao-42.` }; + if (!isValidSessionId(sessionId)) + return { text: `Invalid session ID: ${rest}. Expected format like ao-42.` }; const result = tryRunAo(config, ["send", sessionId, "Please retry the failed task."]); if (!result.ok) return { text: `Failed to send retry:\n${result.error}` }; return { text: `Retry sent to session ${sessionId}.` }; @@ -368,7 +717,8 @@ export default function (api: any) { case "kill": { if (!rest) return { text: "Usage: /ao kill " }; const sessionId = sanitizeArg(rest.trim()); - if (!isValidSessionId(sessionId)) return { text: `Invalid session ID: ${rest}. Expected format like ao-42.` }; + if (!isValidSessionId(sessionId)) + return { text: `Invalid session ID: ${rest}. Expected format like ao-42.` }; const result = tryRunAo(config, ["session", "kill", sessionId]); if (!result.ok) return { text: `Failed to kill session:\n${result.error}` }; return { text: `Session ${sessionId} killed.` }; @@ -398,14 +748,31 @@ export default function (api: any) { else steps.push("❌ Failed to set tools.profile"); // 2. Allow plugin tools - if (runSetup("openclaw", ["config", "set", "tools.allow", '["group:plugins"]'])) - steps.push("✅ tools.allow → group:plugins"); - else steps.push("❌ Failed to set tools.allow"); + const mergedToolsAllow = mergeStringLists( + readOpenClawStringArraySetting("tools.allow", ["tools", "allow"]), + ["group:plugins"], + ); + if ( + runSetup("openclaw", ["config", "set", "tools.allow", JSON.stringify(mergedToolsAllow)]) + ) { + steps.push(`✅ tools.allow → ${mergedToolsAllow.join(", ")}`); + } else steps.push("❌ Failed to set tools.allow"); // 3. Trust the plugin - if (runSetup("openclaw", ["config", "set", "plugins.allow", '["agent-orchestrator"]'])) - steps.push("✅ plugins.allow → agent-orchestrator"); - else steps.push("❌ Failed to set plugins.allow"); + const mergedPluginsAllow = mergeStringLists( + readOpenClawStringArraySetting("plugins.allow", ["plugins", "allow"]), + ["agent-orchestrator"], + ); + if ( + runSetup("openclaw", [ + "config", + "set", + "plugins.allow", + JSON.stringify(mergedPluginsAllow), + ]) + ) { + steps.push(`✅ plugins.allow → ${mergedPluginsAllow.join(", ")}`); + } else steps.push("❌ Failed to set plugins.allow"); // 4. Group chat settings if (runSetup("openclaw", ["config", "set", "messages.groupChat.historyLimit", "100"])) @@ -415,6 +782,12 @@ export default function (api: any) { steps.push(""); steps.push("⚡ Restart the gateway to apply: pm2 restart openclaw-gateway"); steps.push("Then verify with: /ao doctor"); + steps.push(""); + steps.push("⚠️ Action required — run these once to avoid conflicts:"); + steps.push(" openclaw config set skills.entries.coding-agent.enabled false"); + steps.push(" openclaw config set skills.entries.gh-issues.enabled false"); + steps.push(' openclaw config set tools.deny \'["exec","write","str_replace_based_edit_tool","create_file","str_replace_editor"]\''); + steps.push("Without these, the bot may code directly instead of delegating to AO."); return { text: `AO Plugin Setup\n\n${steps.join("\n")}` }; } @@ -482,22 +855,22 @@ export default function (api: any) { required: [], }, async execute(_toolCallId: string, params: { repo?: string; labels?: string }) { - const ghArgs = ["issue", "list"]; - if (params.repo) ghArgs.push("-R", params.repo); - if (params.labels) ghArgs.push("--label", params.labels); - ghArgs.push("--state", "open", "--json", "number,title,labels,state,assignees,createdAt,url", "--limit", "30"); - const result = tryRunGh( - config, - ghArgs, - 15_000, - ); + const result = fetchIssues(config, params); if (!result.ok) { return { - content: [{ type: "text", text: `Failed to fetch issues: ${result.error}` }], + content: [{ type: "text", text: result.error }], isError: true, }; } - return { content: [{ type: "text", text: result.output }] }; + + const lines = [formatIssueList(result.issues)]; + if (result.warnings.length > 0) { + lines.push(""); + lines.push("GitHub warnings:"); + lines.push(formatIssueWarnings(result.warnings)); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; }, }); @@ -511,10 +884,20 @@ export default function (api: any) { parameters: { type: "object", properties: { - issue: { type: "string", description: "Issue identifier (e.g. #42). Optional — omit for freeform tasks, then use ao_send to describe the work." }, + issue: { + type: "string", + description: + "Issue identifier (e.g. #42). Optional — omit for freeform tasks, then use ao_send to describe the work.", + }, agent: { type: "string", description: "Override agent plugin (e.g. codex, claude-code)" }, - claimPr: { type: "string", description: "Immediately claim an existing PR number for the session" }, - decompose: { type: "boolean", description: "Decompose issue into subtasks before spawning" }, + claimPr: { + type: "string", + description: "Immediately claim an existing PR number for the session", + }, + decompose: { + type: "boolean", + description: "Decompose issue into subtasks before spawning", + }, }, }, async execute( @@ -541,7 +924,8 @@ export default function (api: any) { } const spawnOutput = params.issue ? result.output - : result.output + "\n\nNote: This is a freeform session (no issue). Use ao_send to describe the task to the agent."; + : result.output + + "\n\nNote: This is a freeform session (no issue). Use ao_send to describe the task to the agent."; return { content: [{ type: "text", text: spawnOutput }] }; }, }); @@ -586,8 +970,12 @@ export default function (api: any) { } }; - batchSpawnFollowUpTimeouts.push(setTimeout(() => checkStatus("Progress check (3 min)"), 3 * 60_000)); - batchSpawnFollowUpTimeouts.push(setTimeout(() => checkStatus("Status update (8 min)"), 8 * 60_000)); + batchSpawnFollowUpTimeouts.push( + setTimeout(() => checkStatus("Progress check (3 min)"), 3 * 60_000), + ); + batchSpawnFollowUpTimeouts.push( + setTimeout(() => checkStatus("Status update (8 min)"), 8 * 60_000), + ); api.logger.info("[ao-batch] Scheduled auto follow-ups at 3min and 8min"); @@ -697,7 +1085,9 @@ export default function (api: any) { isError: true, }; } - return { content: [{ type: "text", text: result.output || "No review comments to address." }] }; + return { + content: [{ type: "text", text: result.output || "No review comments to address." }], + }; }, }); @@ -720,7 +1110,13 @@ export default function (api: any) { }, async execute( _toolCallId: string, - params: { issue?: string; project?: string; fail?: boolean; comment?: string; list?: boolean }, + params: { + issue?: string; + project?: string; + fail?: boolean; + comment?: string; + list?: boolean; + }, ) { const args = ["verify"]; if (params.list) { @@ -729,7 +1125,12 @@ export default function (api: any) { } else { if (!params.issue) { return { - content: [{ type: "text", text: "Need an issue number. Use list: true to see unverified issues." }], + content: [ + { + type: "text", + text: "Need an issue number. Use list: true to see unverified issues.", + }, + ], isError: true, }; } @@ -811,7 +1212,10 @@ export default function (api: any) { properties: { pr: { type: "string", description: "Pull request number or URL" }, sessionId: { type: "string", description: "Session name (optional)" }, - assignOnGithub: { type: "boolean", description: "Assign the PR to the authenticated GitHub user" }, + assignOnGithub: { + type: "boolean", + description: "Assign the PR to the authenticated GitHub user", + }, }, required: ["pr"], }, @@ -895,7 +1299,7 @@ export default function (api: any) { let boardScanInterval: ReturnType | null = null; let boardScanInitialTimeout: ReturnType | null = null; const batchSpawnFollowUpTimeouts: ReturnType[] = []; - let lastKnownIssueIds: Set = new Set(); + let lastKnownIssueIds: Set = new Set(); let isFirstBoardScan = true; // --- Health monitor --- @@ -936,8 +1340,19 @@ export default function (api: any) { const scan = () => { try { - const issues = fetchIssues(config); - const currentIds = new Set(issues.map((i) => i.number)); + const issuesResult = fetchIssues(config); + if (!issuesResult.ok) { + api.logger.warn(`[ao-board-scanner] ${issuesResult.error}`); + return; + } + + if (issuesResult.warnings.length > 0) { + api.logger.warn( + `[ao-board-scanner] Partial GitHub failures:\n${formatIssueWarnings(issuesResult.warnings)}`, + ); + } + + const currentIds = new Set(issuesResult.issues.map((issue) => getIssueIdentity(issue))); if (isFirstBoardScan) { lastKnownIssueIds = currentIds; @@ -946,15 +1361,12 @@ export default function (api: any) { return; } - const newIssues = issues.filter((i) => !lastKnownIssueIds.has(i.number)); + const newIssues = issuesResult.issues.filter( + (issue) => !lastKnownIssueIds.has(getIssueIdentity(issue)), + ); if (newIssues.length > 0) { - const summary = newIssues - .map((i) => { - const labels = i.labels.map((l) => l.name).join(", "); - return `#${i.number} — ${i.title}${labels ? ` [${labels}]` : ""}`; - }) - .join("\n"); + const summary = formatIssueList(newIssues); api.logger.info(`[ao-board-scanner] ${newIssues.length} new issue(s)`); diff --git a/openclaw-plugin/openclaw.plugin.json b/openclaw-plugin/openclaw.plugin.json index 733faf13b..deeb5f32f 100644 --- a/openclaw-plugin/openclaw.plugin.json +++ b/openclaw-plugin/openclaw.plugin.json @@ -6,6 +6,7 @@ "configSchema": { "type": "object", "additionalProperties": false, + "required": ["aoCwd"], "properties": { "aoPath": { "type": "string", diff --git a/packages/cli/__tests__/commands/setup.test.ts b/packages/cli/__tests__/commands/setup.test.ts index 0226c82dc..5e43f39fc 100644 --- a/packages/cli/__tests__/commands/setup.test.ts +++ b/packages/cli/__tests__/commands/setup.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { Command } from "commander"; +import { homedir } from "node:os"; +import { join } from "node:path"; // --------------------------------------------------------------------------- // Hoisted mocks — must be defined before any imports that use them @@ -9,9 +11,11 @@ const { mockFindConfigFile } = vi.hoisted(() => ({ mockFindConfigFile: vi.fn(), })); -const { mockReadFileSync, mockWriteFileSync } = vi.hoisted(() => ({ +const { mockReadFileSync, mockWriteFileSync, mockExistsSync, mockMkdirSync } = vi.hoisted(() => ({ mockReadFileSync: vi.fn(), mockWriteFileSync: vi.fn(), + mockExistsSync: vi.fn(), + mockMkdirSync: vi.fn(), })); const { mockProbeGateway, mockValidateToken } = vi.hoisted(() => ({ @@ -29,7 +33,8 @@ vi.mock("node:fs", async (importOriginal) => { ...actual, readFileSync: (...args: unknown[]) => mockReadFileSync(...args), writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args), - existsSync: () => true, + existsSync: (...args: unknown[]) => mockExistsSync(...args), + mkdirSync: (...args: unknown[]) => mockMkdirSync(...args), }; }); @@ -93,6 +98,8 @@ describe("setup openclaw command", () => { mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml"); mockReadFileSync.mockReturnValue(MINIMAL_CONFIG); mockWriteFileSync.mockImplementation(() => {}); + mockExistsSync.mockReturnValue(false); + mockMkdirSync.mockImplementation(() => undefined); mockValidateToken.mockResolvedValue({ valid: true }); mockProbeGateway.mockResolvedValue({ reachable: false }); @@ -109,9 +116,14 @@ describe("setup openclaw command", () => { const program = createProgram(); await program.parseAsync([ - "node", "test", "setup", "openclaw", - "--url", "http://127.0.0.1:18789/hooks/agent", - "--token", "test-token", + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--token", + "test-token", "--non-interactive", ]); @@ -128,8 +140,12 @@ describe("setup openclaw command", () => { const program = createProgram(); await program.parseAsync([ - "node", "test", "setup", "openclaw", - "--url", "http://127.0.0.1:18789/hooks/agent", + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", "--non-interactive", ]); @@ -143,8 +159,12 @@ describe("setup openclaw command", () => { const program = createProgram(); await program.parseAsync([ - "node", "test", "setup", "openclaw", - "--token", "tok", + "node", + "test", + "setup", + "openclaw", + "--token", + "tok", "--non-interactive", ]); @@ -153,13 +173,37 @@ describe("setup openclaw command", () => { expect(mockWriteFileSync).toHaveBeenCalled(); }); + it("normalizes OPENCLAW_GATEWAY_URL without double-appending hooks path", async () => { + process.env["OPENCLAW_GATEWAY_URL"] = "http://remote:18789/hooks/agent"; + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--token", + "tok", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("url: http://remote:18789/hooks/agent"); + expect(writtenYaml).not.toContain("/hooks/agent/hooks/agent"); + }); + it("skips token validation and writes config 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", "good-token", + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--token", + "good-token", "--non-interactive", ]); @@ -175,9 +219,14 @@ describe("setup openclaw command", () => { const program = createProgram(); await program.parseAsync([ - "node", "test", "setup", "openclaw", - "--url", "http://127.0.0.1:18789/hooks/agent", - "--token", "tok", + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--token", + "tok", "--non-interactive", ]); @@ -192,28 +241,37 @@ describe("setup openclaw command", () => { const program = createProgram(); await program.parseAsync([ - "node", "test", "setup", "openclaw", - "--url", "http://127.0.0.1:18789/hooks/agent", - "--token", "tok", + "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 matches = writtenYaml.match(/openclaw/g); // "openclaw" appears in: defaults.notifiers list, notifiers.openclaw key, - // plugin: openclaw, and the token ref. Should not have extra duplicates. - // Count just in defaults section - const defaultsSection = writtenYaml.split("notifiers:")[0] + writtenYaml.split("notifiers:")[1]; - expect(defaultsSection).toBeDefined(); + // plugin: openclaw, and the token ref. Verify it appears as a list item exactly once + // (i.e. no duplication in defaults.notifiers). + const listItemMatches = writtenYaml.match(/^\s+- openclaw$/gm); + expect(listItemMatches).toHaveLength(1); }); it("writes correct notifier block structure", async () => { const program = createProgram(); await program.parseAsync([ - "node", "test", "setup", "openclaw", - "--url", "http://custom:9999/hooks/agent", - "--token", "tok", + "node", + "test", + "setup", + "openclaw", + "--url", + "http://custom:9999/hooks/agent", + "--token", + "tok", "--non-interactive", ]); @@ -226,13 +284,76 @@ describe("setup openclaw command", () => { expect(writtenYaml).toContain("wakeMode: now"); }); + it("merges existing allowedSessionKeyPrefixes in openclaw.json", async () => { + const openclawConfigPath = join(homedir(), ".openclaw", "openclaw.json"); + + mockExistsSync.mockImplementation((path: string) => path === openclawConfigPath); + mockReadFileSync.mockImplementation((path: string) => { + if (path === "/tmp/agent-orchestrator.yaml") { + return MINIMAL_CONFIG; + } + if (path === openclawConfigPath) { + return JSON.stringify({ + hooks: { + enabled: false, + token: "old-token", + allowRequestSessionKey: false, + allowedSessionKeyPrefixes: ["legacy:", "hook:"], + }, + otherConfig: true, + }); + } + return ""; + }); + + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--token", + "new-token", + "--non-interactive", + ]); + + const openclawWrite = mockWriteFileSync.mock.calls.find( + ([path]) => path === openclawConfigPath, + ); + expect(openclawWrite).toBeDefined(); + + const writtenJson = JSON.parse(openclawWrite![1] as string) as { + hooks: { + token: string; + enabled: boolean; + allowRequestSessionKey: boolean; + allowedSessionKeyPrefixes: string[]; + }; + otherConfig: boolean; + }; + + expect(writtenJson.otherConfig).toBe(true); + expect(writtenJson.hooks.token).toBe("new-token"); + expect(writtenJson.hooks.enabled).toBe(true); + expect(writtenJson.hooks.allowRequestSessionKey).toBe(true); + expect(writtenJson.hooks.allowedSessionKeyPrefixes).toEqual(["legacy:", "hook:"]); + }); + it("preserves existing projects in config", async () => { const program = createProgram(); await program.parseAsync([ - "node", "test", "setup", "openclaw", - "--url", "http://127.0.0.1:18789/hooks/agent", - "--token", "tok", + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--token", + "tok", "--non-interactive", ]); @@ -246,9 +367,14 @@ describe("setup openclaw command", () => { const program = createProgram(); await program.parseAsync([ - "node", "test", "setup", "openclaw", - "--url", "http://127.0.0.1:18789/hooks/agent", - "--token", "tok", + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--token", + "tok", "--non-interactive", ]); @@ -267,9 +393,14 @@ describe("setup openclaw command", () => { await expect( program.parseAsync([ - "node", "test", "setup", "openclaw", - "--url", "http://127.0.0.1:18789/hooks/agent", - "--token", "tok", + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--token", + "tok", "--non-interactive", ]), ).rejects.toThrow("process.exit"); @@ -283,9 +414,14 @@ describe("setup openclaw command", () => { const program = createProgram(); await program.parseAsync([ - "node", "test", "setup", "openclaw", - "--url", "http://127.0.0.1:18789/hooks/agent", - "--token", "bad-token", + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--token", + "bad-token", "--non-interactive", ]); @@ -302,8 +438,12 @@ describe("setup openclaw command", () => { await expect( program.parseAsync([ - "node", "test", "setup", "openclaw", - "--token", "tok", + "node", + "test", + "setup", + "openclaw", + "--token", + "tok", "--non-interactive", ]), ).rejects.toThrow("process.exit"); @@ -316,8 +456,12 @@ describe("setup openclaw command", () => { const program = createProgram(); await program.parseAsync([ - "node", "test", "setup", "openclaw", - "--url", "http://127.0.0.1:18789/hooks/agent", + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", "--non-interactive", ]); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index d97e5c29d..2dbe05745 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -199,7 +199,7 @@ export function registerDoctor(program: Command): void { } } catch (err) { const message = err instanceof Error ? err.message : String(err); - warn(`Could not load config for notifier checks: ${message}`); + warn(`Notifier checks failed: ${message}`); } } else if (opts.testNotify) { fail("No config file found. Cannot test notifiers without agent-orchestrator.yaml"); diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 781430216..0ca689509 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -27,7 +27,10 @@ import { // --------------------------------------------------------------------------- export class SetupAbortedError extends Error { - constructor(message: string, public readonly exitCode: number = 1) { + constructor( + message: string, + public readonly exitCode: number = 1, + ) { super(message); this.name = "SetupAbortedError"; } @@ -44,6 +47,11 @@ interface ResolvedConfig { token: string; } +function normalizeOpenClawHooksUrl(url: string): string { + const normalized = url.trim().replace(/\/+$/, ""); + return normalized.endsWith(HOOKS_PATH) ? normalized : `${normalized}${HOOKS_PATH}`; +} + // --------------------------------------------------------------------------- // Interactive prompts (dynamic import to keep @clack/prompts optional) // --------------------------------------------------------------------------- @@ -85,10 +93,7 @@ async function interactiveSetup(existingUrl?: string): Promise { } // Normalize: ensure URL ends with /hooks/agent - let url = urlInput as string; - if (!url.endsWith(HOOKS_PATH)) { - url = url.replace(/\/+$/, "") + HOOKS_PATH; - } + const url = normalizeOpenClawHooksUrl(urlInput as string); // --- Step 2: Token --------------------------------------------------------- const envToken = process.env["OPENCLAW_HOOKS_TOKEN"]; @@ -177,30 +182,23 @@ async function interactiveSetup(existingUrl?: string): Promise { // --------------------------------------------------------------------------- async function nonInteractiveSetup(opts: SetupOptions): Promise { - let url = - opts.url ?? - (process.env["OPENCLAW_GATEWAY_URL"] - ? `${process.env["OPENCLAW_GATEWAY_URL"]}${HOOKS_PATH}` - : undefined); + const rawUrl = opts.url ?? process.env["OPENCLAW_GATEWAY_URL"]; const token = opts.token ?? process.env["OPENCLAW_HOOKS_TOKEN"]; - if (!url) { + 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", + " Example: ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --token YOUR_TOKEN --non-interactive", ); } + let url = rawUrl; if (!url.startsWith("http://") && !url.startsWith("https://")) { - throw new SetupAbortedError( - "Error: --url must start with http:// or https://", - ); + throw new SetupAbortedError("Error: --url must start with http:// or https://"); } // Normalize: ensure URL ends with /hooks/agent - if (!url.endsWith(HOOKS_PATH)) { - url = url.replace(/\/+$/, "") + HOOKS_PATH; - } + url = normalizeOpenClawHooksUrl(url); const resolvedToken = token ?? randomBytes(32).toString("base64url"); if (!token) { @@ -230,9 +228,10 @@ function writeOpenClawConfig( // eslint-disable-next-line @typescript-eslint/no-explicit-any const rawConfig = (doc.toJS() as Record) ?? {}; - // Add notifiers.openclaw block — write the env-var placeholder so the raw - // token is never committed to version control. ao setup openclaw exports the - // real value to the shell profile; runtime reads it via the env var. + // Write the env-var placeholder so the raw token is never committed to + // version control. ao setup openclaw exports the real value to the shell + // profile; the notifier plugin resolves it at runtime (env var → openclaw.json + // fallback for daemon contexts where the shell profile isn't sourced). if (!rawConfig.notifiers) rawConfig.notifiers = {}; rawConfig.notifiers.openclaw = { plugin: "openclaw", @@ -303,14 +302,22 @@ function writeOpenClawJsonConfig(token: string): boolean { } // Merge the hooks block (preserve other existing keys in hooks if any) - const existingHooks = - (config.hooks as Record | undefined) ?? {}; + const existingHooks = (config.hooks as Record | undefined) ?? {}; + const existingPrefixes = Array.isArray(existingHooks.allowedSessionKeyPrefixes) + ? existingHooks.allowedSessionKeyPrefixes.filter( + (prefix): prefix is string => typeof prefix === "string", + ) + : []; + const allowedSessionKeyPrefixes = existingPrefixes.includes("hook:") + ? existingPrefixes + : [...existingPrefixes, "hook:"]; + config.hooks = { ...existingHooks, enabled: true, token, allowRequestSessionKey: true, - allowedSessionKeyPrefixes: ["hook:"], + allowedSessionKeyPrefixes, }; writeFileSync(openclawJsonPath, JSON.stringify(config, null, 2) + "\n"); @@ -337,15 +344,15 @@ function writeShellExport(token: string): string | undefined { const safeToken = token.replace(/'/g, "'\\''"); const exportLine = `export OPENCLAW_HOOKS_TOKEN='${safeToken}'`; - // Check if it already exists + // Check if it already exists (use the same regex for detection and replacement + // to avoid silent no-ops when the line is commented, lacks the export prefix, + // or has leading whitespace) + const existingExportRegex = /^\s*(?:export\s+)?OPENCLAW_HOOKS_TOKEN=.*$/m; if (existsSync(profilePath)) { const content = readFileSync(profilePath, "utf-8"); - if (content.includes("OPENCLAW_HOOKS_TOKEN=")) { + if (existingExportRegex.test(content)) { // Replace the existing line - const updated = content.replace( - /^export OPENCLAW_HOOKS_TOKEN=.*$/m, - exportLine, - ); + const updated = content.replace(existingExportRegex, exportLine); writeFileSync(profilePath, updated); return profilePath; } @@ -353,11 +360,9 @@ function writeShellExport(token: string): string | undefined { // Append const prefix = existsSync(profilePath) ? "\n" : ""; - writeFileSync( - profilePath, - `${prefix}# Added by ao setup openclaw\n${exportLine}\n`, - { flag: "a" }, - ); + writeFileSync(profilePath, `${prefix}# Added by ao setup openclaw\n${exportLine}\n`, { + flag: "a", + }); return profilePath; } catch { return undefined; @@ -372,7 +377,9 @@ function printOpenClawInstructions( if (openclawConfigWritten) { // Both configs written automatically if (nonInteractive) { - console.log(chalk.green("✓ Both configs written (agent-orchestrator.yaml + ~/.openclaw/openclaw.json)")); + console.log( + chalk.green("✓ Both configs written (agent-orchestrator.yaml + ~/.openclaw/openclaw.json)"), + ); if (shellProfilePath) { console.log(chalk.green(`✓ OPENCLAW_HOOKS_TOKEN exported in ${shellProfilePath}`)); } @@ -391,7 +398,7 @@ function printOpenClawInstructions( const instructions = ` ${chalk.bold("OpenClaw-side config required")} -Add this to your OpenClaw config (${chalk.dim("~/.openclaw/openclaw.json")}): +AO config was written successfully. Add this to your OpenClaw config (${chalk.dim("~/.openclaw/openclaw.json")}): ${chalk.cyan(`{ "hooks": { @@ -401,21 +408,15 @@ Add this to your OpenClaw config (${chalk.dim("~/.openclaw/openclaw.json")}): "allowedSessionKeyPrefixes": ["hook:"] } }`)} - -Then set the token as an environment variable for AO: - - ${chalk.cyan('export OPENCLAW_HOOKS_TOKEN=""')} - -Add it to your shell profile (~/.zshrc or ~/.bashrc) to persist.`; +`; if (nonInteractive) { console.log("\nOpenClaw-side config required:"); - console.log('Add to ~/.openclaw/openclaw.json:'); - console.log(' hooks.enabled: true'); + console.log("AO config was written successfully. Add to ~/.openclaw/openclaw.json:"); + console.log(" hooks.enabled: true"); console.log(' hooks.token: ""'); - console.log(' hooks.allowRequestSessionKey: true'); + console.log(" hooks.allowRequestSessionKey: true"); console.log(' hooks.allowedSessionKeyPrefixes: ["hook:"]'); - console.log('\nSet env var: export OPENCLAW_HOOKS_TOKEN=""'); } else { console.log(instructions); } @@ -427,16 +428,14 @@ Add it to your shell profile (~/.zshrc or ~/.bashrc) to persist.`; // --------------------------------------------------------------------------- export function registerSetup(program: Command): void { - const setup = program - .command("setup") - .description("Set up integrations with external services"); + const setup = program.command("setup").description("Set up integrations with external services"); setup .command("openclaw") .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 and --token (or env vars)") + .option("--non-interactive", "Skip prompts — requires --url (token auto-generated if not provided)") .action(async (opts: SetupOptions) => { try { await runSetupAction(opts); @@ -451,79 +450,79 @@ export function registerSetup(program: Command): void { } async function runSetupAction(opts: SetupOptions): Promise { - const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; + const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; - // --- Find existing config ------------------------------------------------ - let configPath: string | undefined; - try { - const found = findConfigFile(); - configPath = found ?? undefined; - } catch { - // no config found - } + // --- Find existing config ------------------------------------------------ + let configPath: string | undefined; + try { + const found = findConfigFile(); + configPath = found ?? undefined; + } catch { + // no config found + } - if (!configPath) { - throw new SetupAbortedError( - "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", - ); - } + if (!configPath) { + throw new SetupAbortedError( + "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", + ); + } - // --- Check for existing openclaw config ---------------------------------- - const rawYaml = readFileSync(configPath, "utf-8"); - const rawConfig = yamlParse(rawYaml) ?? {}; - const existingOpenClaw = rawConfig?.notifiers?.openclaw; - const existingUrl = existingOpenClaw?.url as string | undefined; + // --- Check for existing openclaw config ---------------------------------- + const rawYaml = readFileSync(configPath, "utf-8"); + const rawConfig = yamlParse(rawYaml) ?? {}; + const existingOpenClaw = rawConfig?.notifiers?.openclaw; + const existingUrl = existingOpenClaw?.url as string | undefined; - if (existingOpenClaw && !nonInteractive) { - const clack = await import("@clack/prompts"); - const reconfigure = await clack.confirm({ - message: "OpenClaw is already configured. Reconfigure?", - initialValue: false, - }); + if (existingOpenClaw && !nonInteractive) { + const clack = await import("@clack/prompts"); + const reconfigure = await clack.confirm({ + message: "OpenClaw is already configured. Reconfigure?", + initialValue: false, + }); - if (clack.isCancel(reconfigure) || !reconfigure) { - console.log(chalk.dim("Keeping existing config.")); - return; - } - } + if (clack.isCancel(reconfigure) || !reconfigure) { + console.log(chalk.dim("Keeping existing config.")); + return; + } + } - // --- Run setup ----------------------------------------------------------- - let resolved: ResolvedConfig; + // --- Run setup ----------------------------------------------------------- + let resolved: ResolvedConfig; - if (nonInteractive) { - resolved = await nonInteractiveSetup(opts); - } else { - resolved = await interactiveSetup(existingUrl); - } + if (nonInteractive) { + resolved = await nonInteractiveSetup(opts); + } else { + resolved = await interactiveSetup(existingUrl); + } - // --- Write AO config ----------------------------------------------------- - writeOpenClawConfig(configPath, resolved, nonInteractive); + // --- Write AO config ----------------------------------------------------- + writeOpenClawConfig(configPath, resolved, nonInteractive); - // --- Write OpenClaw config ----------------------------------------------- - const openclawConfigWritten = writeOpenClawJsonConfig(resolved.token); - if (openclawConfigWritten && nonInteractive) { - console.log(chalk.green("✓ Wrote hooks config to ~/.openclaw/openclaw.json")); - } + // --- Write OpenClaw config ----------------------------------------------- + const openclawConfigWritten = writeOpenClawJsonConfig(resolved.token); + if (openclawConfigWritten && nonInteractive) { + console.log(chalk.green("✓ Wrote hooks config to ~/.openclaw/openclaw.json")); + } - // --- Write shell export -------------------------------------------------- - const shellProfilePath = writeShellExport(resolved.token); - if (shellProfilePath && nonInteractive) { - console.log(chalk.green(`✓ Exported OPENCLAW_HOOKS_TOKEN in ${shellProfilePath}`)); - } + // --- Write shell export -------------------------------------------------- + const shellProfilePath = writeShellExport(resolved.token); + if (shellProfilePath && nonInteractive) { + console.log(chalk.green(`✓ Exported OPENCLAW_HOOKS_TOKEN in ${shellProfilePath}`)); + } - // --- Print instructions -------------------------------------------------- - printOpenClawInstructions(nonInteractive, openclawConfigWritten, shellProfilePath); + // --- Print instructions -------------------------------------------------- + printOpenClawInstructions(nonInteractive, openclawConfigWritten, shellProfilePath); - // --- Done ---------------------------------------------------------------- - if (!nonInteractive) { - const clack = await import("@clack/prompts"); - clack.outro( - `${chalk.green("Setup complete!")} AO will send notifications to OpenClaw.\n` + - chalk.dim(" Run 'ao doctor' to verify the full setup.\n") + - chalk.dim(" Restart AO with 'ao stop && ao start' to activate."), - ); - } else { - console.log(chalk.green("\n✓ OpenClaw setup complete.")); - console.log(chalk.dim("Restart AO to activate: ao stop && ao start")); - } + // --- Done ---------------------------------------------------------------- + if (!nonInteractive) { + const clack = await import("@clack/prompts"); + clack.outro( + `${chalk.green("Setup complete!")} AO will send notifications to OpenClaw.\n` + + chalk.dim(" Run 'ao doctor' to verify the full setup.\n") + + chalk.dim(" Restart AO with 'ao stop && ao start' to activate."), + ); + } else { + console.log(chalk.green("\n✓ OpenClaw setup complete.")); + console.log(chalk.dim("Restart AO to activate: ao stop && ao start")); + } } diff --git a/packages/cli/src/lib/config-instruction.ts b/packages/cli/src/lib/config-instruction.ts index 9610a9a21..004bd1eb3 100644 --- a/packages/cli/src/lib/config-instruction.ts +++ b/packages/cli/src/lib/config-instruction.ts @@ -22,7 +22,7 @@ defaults: agent: claude-code # claude-code | aider | codex | opencode workspace: worktree # worktree | clone notifiers: # List of active notifier plugins - - desktop # desktop | slack | webhook | composio | openclaw + - desktop # desktop | discord | slack | webhook | composio | openclaw orchestrator: agent: claude-code # Agent for orchestrator sessions (optional override) worker: diff --git a/packages/plugins/notifier-discord/package.json b/packages/plugins/notifier-discord/package.json index f35f81c72..b598c13d8 100644 --- a/packages/plugins/notifier-discord/package.json +++ b/packages/plugins/notifier-discord/package.json @@ -31,13 +31,14 @@ "build": "tsc", "typecheck": "tsc --noEmit", "test": "vitest run", - "clean": "rm -rf dist" + "clean": "rimraf dist" }, "dependencies": { "@composio/ao-core": "workspace:*" }, "devDependencies": { "@types/node": "^25.2.3", + "rimraf": "^6.0.0", "typescript": "^5.7.0", "vitest": "^3.0.0" } diff --git a/packages/plugins/notifier-discord/src/index.test.ts b/packages/plugins/notifier-discord/src/index.test.ts index c2af7d61f..cfa0cf41c 100644 --- a/packages/plugins/notifier-discord/src/index.test.ts +++ b/packages/plugins/notifier-discord/src/index.test.ts @@ -195,7 +195,7 @@ describe("notifier-discord", () => { }); it("handles 204 No Content as success", async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 204 }); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 204 }); vi.stubGlobal("fetch", fetchMock); const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" }); diff --git a/packages/plugins/notifier-discord/src/index.ts b/packages/plugins/notifier-discord/src/index.ts index bed6e86a6..51b8100fb 100644 --- a/packages/plugins/notifier-discord/src/index.ts +++ b/packages/plugins/notifier-discord/src/index.ts @@ -179,7 +179,7 @@ export function create(config?: Record): Notifier { // Discord requires thread_id as a URL query param, not in the JSON body const effectiveUrl = webhookUrl && threadId - ? `${webhookUrl}${webhookUrl.includes("?") ? "&" : "?"}thread_id=${threadId}` + ? `${webhookUrl}${webhookUrl.includes("?") ? "&" : "?"}thread_id=${encodeURIComponent(threadId)}` : webhookUrl; function buildPayload(embeds: DiscordEmbed[]): Record { diff --git a/packages/plugins/notifier-openclaw/README.md b/packages/plugins/notifier-openclaw/README.md index 91e525fd0..0ffcb757f 100644 --- a/packages/plugins/notifier-openclaw/README.md +++ b/packages/plugins/notifier-openclaw/README.md @@ -8,7 +8,7 @@ OpenClaw notifier plugin for AO escalation events. ao setup openclaw ``` -This interactive wizard auto-detects your OpenClaw gateway, validates the connection, and writes the config. For non-interactive use (e.g., from an OpenClaw agent): +This interactive wizard auto-detects your OpenClaw gateway, validates the connection, and writes the config. For non-interactive use (e.g., in CI/CD pipelines or automation scripts): ```bash ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --token YOUR_TOKEN --non-interactive diff --git a/packages/plugins/notifier-openclaw/src/index.ts b/packages/plugins/notifier-openclaw/src/index.ts index dc6a65677..76a56938f 100644 --- a/packages/plugins/notifier-openclaw/src/index.ts +++ b/packages/plugins/notifier-openclaw/src/index.ts @@ -1,3 +1,6 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { type EventPriority, type Notifier, @@ -8,6 +11,25 @@ import { } from "@composio/ao-core"; import { isRetryableHttpStatus, normalizeRetryConfig, validateUrl } from "@composio/ao-core/utils"; +/** + * Read the hooks token from ~/.openclaw/openclaw.json as a fallback for + * daemon contexts where the shell profile (and OPENCLAW_HOOKS_TOKEN) isn't + * sourced. This file is written by `ao setup openclaw` and lives outside + * the project directory so it's never committed to version control. + */ +function readTokenFromOpenClawConfig(): string | undefined { + try { + const configPath = join(homedir(), ".openclaw", "openclaw.json"); + if (!existsSync(configPath)) return undefined; + const raw = readFileSync(configPath, "utf-8"); + const config = JSON.parse(raw) as Record; + const token = (config.hooks as Record | undefined)?.token; + return typeof token === "string" && token ? token : undefined; + } catch { + return undefined; + } +} + export const manifest = { name: "openclaw", slot: "notifier" as const, @@ -55,8 +77,8 @@ async function postWithRetry( if (response.status === 401 || response.status === 403) { lastError = new Error( `OpenClaw rejected the auth token (HTTP ${response.status}).\n` + - ` Check that hooks.token in your OpenClaw config matches OPENCLAW_HOOKS_TOKEN.\n` + - ` Reconfigure: ao setup openclaw`, + ` Check that hooks.token in your OpenClaw config matches the token configured for AO.\n` + + ` Reconfigure: ao setup openclaw`, ); throw lastError; } @@ -79,8 +101,8 @@ async function postWithRetry( if (lastError.message.includes("ECONNREFUSED")) { throw new Error( `Can't reach OpenClaw gateway at ${url}.\n` + - ` Is OpenClaw running? Check: openclaw status\n` + - ` Wrong URL? Run: ao setup openclaw`, + ` Is OpenClaw running? Check: openclaw status\n` + + ` Wrong URL? Run: ao setup openclaw`, ); } @@ -133,13 +155,26 @@ function formatActionsLine(actions: NotifyAction[]): string { return `Actions available: ${labels}`; } +/** + * Resolve a token value that may be a `${ENV_VAR}` placeholder (as written + * into agent-orchestrator.yaml by `ao setup openclaw`) or a literal string. + * Returns undefined for empty/unresolvable values so callers can chain `??`. + */ +function resolveEnvVarToken(raw: unknown): string | undefined { + if (typeof raw !== "string" || !raw) return undefined; + const match = raw.match(/^\$\{([^}]+)\}$/); + if (match) return process.env[match[1]] || undefined; + return raw; +} + export function create(config?: Record): Notifier { const url = (typeof config?.url === "string" ? config.url : undefined) ?? "http://127.0.0.1:18789/hooks/agent"; const token = - (typeof config?.token === "string" ? config.token : undefined) ?? - process.env.OPENCLAW_HOOKS_TOKEN; + resolveEnvVarToken(config?.token) ?? + process.env.OPENCLAW_HOOKS_TOKEN ?? + readTokenFromOpenClawConfig(); const senderName = typeof config?.name === "string" ? config.name : "AO"; const sessionKeyPrefix = typeof config?.sessionKeyPrefix === "string" ? config.sessionKeyPrefix : "hook:ao:"; @@ -153,8 +188,8 @@ export function create(config?: Record): Notifier { if (!token) { console.warn( "[notifier-openclaw] No token configured.\n" + - " Set OPENCLAW_HOOKS_TOKEN env var, or add token to your notifier config.\n" + - " Run: ao setup openclaw", + " Set OPENCLAW_HOOKS_TOKEN env var, or add token to your notifier config.\n" + + " Run: ao setup openclaw", ); }