From d3428053d95eefbe10ecf04f92218ffcba55ae5a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Feb 2026 17:32:19 +0100 Subject: [PATCH] fix: redact config values in skills status --- skills/discord/SKILL.md | 576 +++---------------- src/agents/skills-status.ts | 2 - src/gateway/server.skills-status.e2e.test.ts | 72 +++ src/hooks/hooks-status.ts | 2 - src/shared/requirements.test.ts | 6 +- src/shared/requirements.ts | 9 +- ui/src/ui/types.ts | 1 - 7 files changed, 154 insertions(+), 514 deletions(-) create mode 100644 src/gateway/server.skills-status.e2e.test.ts diff --git a/skills/discord/SKILL.md b/skills/discord/SKILL.md index 218de15b8e..1841148648 100644 --- a/skills/discord/SKILL.md +++ b/skills/discord/SKILL.md @@ -1,578 +1,160 @@ --- name: discord -description: Use when you need to control Discord from OpenClaw via the discord tool: send messages, react, post or upload stickers, upload emojis, run polls, manage threads/pins/search, create/edit/delete channels and categories, fetch permissions or member/role/channel info, set bot presence/activity, or handle moderation actions in Discord DMs or channels. -metadata: {"openclaw":{"emoji":"🎮","requires":{"config":["channels.discord"]}}} +description: "Discord ops via the message tool (channel=discord)." +metadata: { "openclaw": { "emoji": "🎮", "requires": { "config": ["channels.discord.token"] } } } +allowed-tools: ["message"] --- -# Discord Actions +# Discord (Via `message`) -## Overview +Use the `message` tool. No provider-specific `discord` tool exposed to the agent. -Use `discord` to manage messages, reactions, threads, polls, and moderation. You can disable groups via `discord.actions.*` (defaults to enabled, except roles/moderation). The tool uses the bot token configured for OpenClaw. +## Musts -## Inputs to collect +- Always: `channel: "discord"`. +- Respect gating: `channels.discord.actions.*` (some default off: `roles`, `moderation`, `presence`, `channels`). +- Prefer explicit ids: `guildId`, `channelId`, `messageId`, `userId`. +- Multi-account: optional `accountId`. -- For reactions: `channelId`, `messageId`, and an `emoji`. -- For fetchMessage: `guildId`, `channelId`, `messageId`, or a `messageLink` like `https://discord.com/channels///`. -- For stickers/polls/sendMessage: a `to` target (`channel:` or `user:`). Optional `content` text. -- Polls also need a `question` plus 2–10 `answers`. -- For media: `mediaUrl` with `file:///path` for local files or `https://...` for remote. -- For emoji uploads: `guildId`, `name`, `mediaUrl`, optional `roleIds` (limit 256KB, PNG/JPG/GIF). -- For sticker uploads: `guildId`, `name`, `description`, `tags`, `mediaUrl` (limit 512KB, PNG/APNG/Lottie JSON). +## Targets -Message context lines include `discord message id` and `channel` fields you can reuse directly. +- Send-like actions: `to: "channel:"` or `to: "user:"`. +- Message-specific actions: `channelId: ""` (or `to`) + `messageId: ""`. -**Note:** `sendMessage` uses `to: "channel:"` format, not `channelId`. Other actions like `react`, `readMessages`, `editMessage` use `channelId` directly. -**Note:** `fetchMessage` accepts message IDs or full links like `https://discord.com/channels///`. +## Common Actions (Examples) -## Actions +Send message: -### React to a message +```json +{ + "action": "send", + "channel": "discord", + "to": "channel:123", + "message": "hello", + "silent": true +} +``` + +Send with media: + +```json +{ + "action": "send", + "channel": "discord", + "to": "channel:123", + "message": "see attachment", + "media": "file:///tmp/example.png" +} +``` + +React: ```json { "action": "react", + "channel": "discord", "channelId": "123", "messageId": "456", "emoji": "✅" } ``` -### List reactions + users +Read: ```json { - "action": "reactions", - "channelId": "123", - "messageId": "456", - "limit": 100 -} -``` - -### Send a sticker - -```json -{ - "action": "sticker", + "action": "read", + "channel": "discord", "to": "channel:123", - "stickerIds": ["9876543210"], - "content": "Nice work!" -} -``` - -- Up to 3 sticker IDs per message. -- `to` can be `user:` for DMs. - -### Upload a custom emoji - -```json -{ - "action": "emojiUpload", - "guildId": "999", - "name": "party_blob", - "mediaUrl": "file:///tmp/party.png", - "roleIds": ["222"] -} -``` - -- Emoji images must be PNG/JPG/GIF and <= 256KB. -- `roleIds` is optional; omit to make the emoji available to everyone. - -### Upload a sticker - -```json -{ - "action": "stickerUpload", - "guildId": "999", - "name": "openclaw_wave", - "description": "OpenClaw waving hello", - "tags": "👋", - "mediaUrl": "file:///tmp/wave.png" -} -``` - -- Stickers require `name`, `description`, and `tags`. -- Uploads must be PNG/APNG/Lottie JSON and <= 512KB. - -### Create a poll - -```json -{ - "action": "poll", - "to": "channel:123", - "question": "Lunch?", - "answers": ["Pizza", "Sushi", "Salad"], - "allowMultiselect": false, - "durationHours": 24, - "content": "Vote now" -} -``` - -- `durationHours` defaults to 24; max 32 days (768 hours). - -### Check bot permissions for a channel - -```json -{ - "action": "permissions", - "channelId": "123" -} -``` - -## Ideas to try - -- React with ✅/⚠️ to mark status updates. -- Post a quick poll for release decisions or meeting times. -- Send celebratory stickers after successful deploys. -- Upload new emojis/stickers for release moments. -- Run weekly “priority check” polls in team channels. -- DM stickers as acknowledgements when a user’s request is completed. - -## Action gating - -Use `discord.actions.*` to disable action groups: - -- `reactions` (react + reactions list + emojiList) -- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search` -- `emojiUploads`, `stickerUploads` -- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events` -- `roles` (role add/remove, default `false`) -- `channels` (channel/category create/edit/delete/move, default `false`) -- `moderation` (timeout/kick/ban, default `false`) -- `presence` (bot status/activity, default `false`) - -### Read recent messages - -```json -{ - "action": "readMessages", - "channelId": "123", "limit": 20 } ``` -### Fetch a single message +Edit / delete: ```json { - "action": "fetchMessage", - "guildId": "999", - "channelId": "123", - "messageId": "456" -} -``` - -```json -{ - "action": "fetchMessage", - "messageLink": "https://discord.com/channels/999/123/456" -} -``` - -### Send/edit/delete a message - -```json -{ - "action": "sendMessage", - "to": "channel:123", - "content": "Hello from OpenClaw" -} -``` - -**With media attachment:** - -```json -{ - "action": "sendMessage", - "to": "channel:123", - "content": "Check out this audio!", - "mediaUrl": "file:///tmp/audio.mp3" -} -``` - -- `to` uses format `channel:` or `user:` for DMs (not `channelId`!) -- `mediaUrl` supports local files (`file:///path/to/file`) and remote URLs (`https://...`) -- Optional `replyTo` with a message ID to reply to a specific message - -```json -{ - "action": "editMessage", + "action": "edit", + "channel": "discord", "channelId": "123", "messageId": "456", - "content": "Fixed typo" + "message": "fixed typo" } ``` ```json { - "action": "deleteMessage", + "action": "delete", + "channel": "discord", "channelId": "123", "messageId": "456" } ``` -### Threads +Poll: ```json { - "action": "threadCreate", - "channelId": "123", - "name": "Bug triage", - "messageId": "456" + "action": "poll", + "channel": "discord", + "to": "channel:123", + "pollQuestion": "Lunch?", + "pollOption": ["Pizza", "Sushi", "Salad"], + "pollMulti": false, + "pollDurationHours": 24 } ``` -```json -{ - "action": "threadList", - "guildId": "999" -} -``` +Pins: ```json { - "action": "threadReply", - "channelId": "777", - "content": "Replying in thread" -} -``` - -### Pins - -```json -{ - "action": "pinMessage", + "action": "pin", + "channel": "discord", "channelId": "123", "messageId": "456" } ``` +Threads: + ```json { - "action": "listPins", - "channelId": "123" + "action": "thread-create", + "channel": "discord", + "channelId": "123", + "messageId": "456", + "threadName": "bug triage" } ``` -### Search messages +Search: ```json { - "action": "searchMessages", + "action": "search", + "channel": "discord", "guildId": "999", - "content": "release notes", + "query": "release notes", "channelIds": ["123", "456"], "limit": 10 } ``` -### Member + role info +Presence (often gated): ```json { - "action": "memberInfo", - "guildId": "999", - "userId": "111" -} -``` - -```json -{ - "action": "roleInfo", - "guildId": "999" -} -``` - -### List available custom emojis - -```json -{ - "action": "emojiList", - "guildId": "999" -} -``` - -### Role changes (disabled by default) - -```json -{ - "action": "roleAdd", - "guildId": "999", - "userId": "111", - "roleId": "222" -} -``` - -### Channel info - -```json -{ - "action": "channelInfo", - "channelId": "123" -} -``` - -```json -{ - "action": "channelList", - "guildId": "999" -} -``` - -### Channel management (disabled by default) - -Create, edit, delete, and move channels and categories. Enable via `discord.actions.channels: true`. - -**Create a text channel:** - -```json -{ - "action": "channelCreate", - "guildId": "999", - "name": "general-chat", - "type": 0, - "parentId": "888", - "topic": "General discussion" -} -``` - -- `type`: Discord channel type integer (0 = text, 2 = voice, 4 = category; other values supported) -- `parentId`: category ID to nest under (optional) -- `topic`, `position`, `nsfw`: optional - -**Create a category:** - -```json -{ - "action": "categoryCreate", - "guildId": "999", - "name": "Projects" -} -``` - -**Edit a channel:** - -```json -{ - "action": "channelEdit", - "channelId": "123", - "name": "new-name", - "topic": "Updated topic" -} -``` - -- Supports `name`, `topic`, `position`, `parentId` (null to remove from category), `nsfw`, `rateLimitPerUser` - -**Move a channel:** - -```json -{ - "action": "channelMove", - "guildId": "999", - "channelId": "123", - "parentId": "888", - "position": 2 -} -``` - -- `parentId`: target category (null to move to top level) - -**Delete a channel:** - -```json -{ - "action": "channelDelete", - "channelId": "123" -} -``` - -**Edit/delete a category:** - -```json -{ - "action": "categoryEdit", - "categoryId": "888", - "name": "Renamed Category" -} -``` - -```json -{ - "action": "categoryDelete", - "categoryId": "888" -} -``` - -### Voice status - -```json -{ - "action": "voiceStatus", - "guildId": "999", - "userId": "111" -} -``` - -### Scheduled events - -```json -{ - "action": "eventList", - "guildId": "999" -} -``` - -### Moderation (disabled by default) - -```json -{ - "action": "timeout", - "guildId": "999", - "userId": "111", - "durationMinutes": 10 -} -``` - -### Bot presence/activity (disabled by default) - -Set the bot's online status and activity. Enable via `discord.actions.presence: true`. - -Discord bots can only set `name`, `state`, `type`, and `url` on an activity. Other Activity fields (details, emoji, assets) are accepted by the gateway but silently ignored by Discord for bots. - -**How fields render by activity type:** - -- **playing, streaming, listening, watching, competing**: `activityName` is shown in the sidebar under the bot's name (e.g. "**with fire**" for type "playing" and name "with fire"). `activityState` is shown in the profile flyout. -- **custom**: `activityName` is ignored. Only `activityState` is displayed as the status text in the sidebar. -- **streaming**: `activityUrl` may be displayed or embedded by the client. - -**Set playing status:** - -```json -{ - "action": "setPresence", + "action": "set-presence", + "channel": "discord", "activityType": "playing", - "activityName": "with fire" + "activityName": "with fire", + "status": "online" } ``` -Result in sidebar: "**with fire**". Flyout shows: "Playing: with fire" +## Writing Style (Discord) -**With state (shown in flyout):** - -```json -{ - "action": "setPresence", - "activityType": "playing", - "activityName": "My Game", - "activityState": "In the lobby" -} -``` - -Result in sidebar: "**My Game**". Flyout shows: "Playing: My Game (newline) In the lobby". - -**Set streaming (optional URL, may not render for bots):** - -```json -{ - "action": "setPresence", - "activityType": "streaming", - "activityName": "Live coding", - "activityUrl": "https://twitch.tv/example" -} -``` - -**Set listening/watching:** - -```json -{ - "action": "setPresence", - "activityType": "listening", - "activityName": "Spotify" -} -``` - -```json -{ - "action": "setPresence", - "activityType": "watching", - "activityName": "the logs" -} -``` - -**Set a custom status (text in sidebar):** - -```json -{ - "action": "setPresence", - "activityType": "custom", - "activityState": "Vibing" -} -``` - -Result in sidebar: "Vibing". Note: `activityName` is ignored for custom type. - -**Set bot status only (no activity/clear status):** - -```json -{ - "action": "setPresence", - "status": "dnd" -} -``` - -**Parameters:** - -- `activityType`: `playing`, `streaming`, `listening`, `watching`, `competing`, `custom` -- `activityName`: text shown in the sidebar for non-custom types (ignored for `custom`) -- `activityUrl`: Twitch or YouTube URL for streaming type (optional; may not render for bots) -- `activityState`: for `custom` this is the status text; for other types it shows in the profile flyout -- `status`: `online` (default), `dnd`, `idle`, `invisible` - -## Discord Writing Style Guide - -**Keep it conversational!** Discord is a chat platform, not documentation. - -### Do - -- Short, punchy messages (1-3 sentences ideal) -- Multiple quick replies > one wall of text -- Use emoji for tone/emphasis 🦞 -- Lowercase casual style is fine -- Break up info into digestible chunks -- Match the energy of the conversation - -### Don't - -- No markdown tables (Discord renders them as ugly raw `| text |`) -- No `## Headers` for casual chat (use **bold** or CAPS for emphasis) -- Avoid multi-paragraph essays -- Don't over-explain simple things -- Skip the "I'd be happy to help!" fluff - -### Formatting that works - -- **bold** for emphasis -- `code` for technical terms -- Lists for multiple items -- > quotes for referencing -- Wrap multiple links in `<>` to suppress embeds - -### Example transformations - -❌ Bad: - -``` -I'd be happy to help with that! Here's a comprehensive overview of the versioning strategies available: - -## Semantic Versioning -Semver uses MAJOR.MINOR.PATCH format where... - -## Calendar Versioning -CalVer uses date-based versions like... -``` - -✅ Good: - -``` -versioning options: semver (1.2.3), calver (2026.01.04), or yolo (`latest` forever). what fits your release cadence? -``` +- Short, conversational, low ceremony. +- No markdown tables. +- Prefer multiple small replies over one wall of text. diff --git a/src/agents/skills-status.ts b/src/agents/skills-status.ts index 34ab7a2a7a..3bdd23b276 100644 --- a/src/agents/skills-status.ts +++ b/src/agents/skills-status.ts @@ -20,7 +20,6 @@ import { resolveBundledSkillsContext } from "./skills/bundled-context.js"; export type SkillStatusConfigCheck = { path: string; - value: unknown; satisfied: boolean; }; @@ -216,7 +215,6 @@ function buildSkillStatus( skillConfig?.env?.[envName] || (skillConfig?.apiKey && entry.metadata?.primaryEnv === envName), ), - resolveConfigValue: (pathStr) => resolveConfigPath(config, pathStr), isConfigSatisfied: (pathStr) => isConfigPathTruthy(config, pathStr), }); const eligible = !disabled && !blockedByAllowlist && requirementsSatisfied; diff --git a/src/gateway/server.skills-status.e2e.test.ts b/src/gateway/server.skills-status.e2e.test.ts new file mode 100644 index 0000000000..c7446e0461 --- /dev/null +++ b/src/gateway/server.skills-status.e2e.test.ts @@ -0,0 +1,72 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + connectOk, + installGatewayTestHooks, + rpcReq, + startServerWithClient, +} from "./test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +async function withServer( + run: (ws: Awaited>["ws"]) => Promise, +) { + const { server, ws, prevToken } = await startServerWithClient("secret"); + try { + return await run(ws); + } finally { + ws.close(); + await server.close(); + if (prevToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; + } + } +} + +describe("gateway skills.status", () => { + it("does not expose raw config values to operator.read clients", async () => { + const prevBundledSkillsDir = process.env.OPENCLAW_BUNDLED_SKILLS_DIR; + process.env.OPENCLAW_BUNDLED_SKILLS_DIR = path.join(process.cwd(), "skills"); + const secret = "discord-token-secret-abc"; + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + session: { mainKey: "main-test" }, + channels: { + discord: { + token: secret, + }, + }, + }); + + try { + await withServer(async (ws) => { + await connectOk(ws, { token: "secret", scopes: ["operator.read"] }); + const res = await rpcReq<{ + skills?: Array<{ + name?: string; + configChecks?: Array<{ path?: string; satisfied?: boolean } & Record>; + }>; + }>(ws, "skills.status", {}); + + expect(res.ok).toBe(true); + expect(JSON.stringify(res.payload)).not.toContain(secret); + + const discord = res.payload?.skills?.find((s) => s.name === "discord"); + expect(discord).toBeTruthy(); + const check = discord?.configChecks?.find((c) => c.path === "channels.discord.token"); + expect(check).toBeTruthy(); + expect(check?.satisfied).toBe(true); + expect(check && "value" in check).toBe(false); + }); + } finally { + if (prevBundledSkillsDir === undefined) { + delete process.env.OPENCLAW_BUNDLED_SKILLS_DIR; + } else { + process.env.OPENCLAW_BUNDLED_SKILLS_DIR = prevBundledSkillsDir; + } + } + }); +}); diff --git a/src/hooks/hooks-status.ts b/src/hooks/hooks-status.ts index dd3e75294a..53ffc5cbb0 100644 --- a/src/hooks/hooks-status.ts +++ b/src/hooks/hooks-status.ts @@ -8,7 +8,6 @@ import { loadWorkspaceHookEntries } from "./workspace.js"; export type HookStatusConfigCheck = { path: string; - value: unknown; satisfied: boolean; }; @@ -124,7 +123,6 @@ function buildHookStatus( localPlatform: process.platform, remotePlatforms: eligibility?.remote?.platforms, isEnvSatisfied: (envName) => Boolean(process.env[envName] || hookConfig?.env?.[envName]), - resolveConfigValue: (pathStr) => resolveConfigPath(config, pathStr), isConfigSatisfied: (pathStr) => isConfigPathTruthy(config, pathStr), }); diff --git a/src/shared/requirements.test.ts b/src/shared/requirements.test.ts index e8f6abbc56..06d48ec2e5 100644 --- a/src/shared/requirements.test.ts +++ b/src/shared/requirements.test.ts @@ -52,14 +52,13 @@ describe("requirements helpers", () => { ).toEqual(["A"]); }); - it("buildConfigChecks includes value+status", () => { + it("buildConfigChecks includes status", () => { expect( buildConfigChecks({ required: ["a.b"], - resolveValue: (p) => (p === "a.b" ? 1 : null), isSatisfied: (p) => p === "a.b", }), - ).toEqual([{ path: "a.b", value: 1, satisfied: true }]); + ).toEqual([{ path: "a.b", satisfied: true }]); }); it("evaluateRequirementsFromMetadata derives required+missing", () => { @@ -72,7 +71,6 @@ describe("requirements helpers", () => { hasLocalBin: (bin) => bin === "a", localPlatform: "linux", isEnvSatisfied: (name) => name === "E", - resolveConfigValue: () => "x", isConfigSatisfied: () => false, }); diff --git a/src/shared/requirements.ts b/src/shared/requirements.ts index 2aa4146146..3bdfe6a536 100644 --- a/src/shared/requirements.ts +++ b/src/shared/requirements.ts @@ -8,7 +8,6 @@ export type Requirements = { export type RequirementConfigCheck = { path: string; - value: unknown; satisfied: boolean; }; @@ -84,13 +83,11 @@ export function resolveMissingEnv(params: { export function buildConfigChecks(params: { required: string[]; - resolveValue: (pathStr: string) => unknown; isSatisfied: (pathStr: string) => boolean; }): RequirementConfigCheck[] { return params.required.map((pathStr) => { - const value = params.resolveValue(pathStr); const satisfied = params.isSatisfied(pathStr); - return { path: pathStr, value, satisfied }; + return { path: pathStr, satisfied }; }); } @@ -103,7 +100,6 @@ export function evaluateRequirements(params: { localPlatform: string; remotePlatforms?: string[]; isEnvSatisfied: (envName: string) => boolean; - resolveConfigValue: (pathStr: string) => unknown; isConfigSatisfied: (pathStr: string) => boolean; }): { missing: Requirements; eligible: boolean; configChecks: RequirementConfigCheck[] } { const missingBins = resolveMissingBins({ @@ -127,7 +123,6 @@ export function evaluateRequirements(params: { }); const configChecks = buildConfigChecks({ required: params.required.config, - resolveValue: params.resolveConfigValue, isSatisfied: params.isConfigSatisfied, }); const missingConfig = configChecks.filter((check) => !check.satisfied).map((check) => check.path); @@ -162,7 +157,6 @@ export function evaluateRequirementsFromMetadata(params: { localPlatform: string; remotePlatforms?: string[]; isEnvSatisfied: (envName: string) => boolean; - resolveConfigValue: (pathStr: string) => unknown; isConfigSatisfied: (pathStr: string) => boolean; }): { required: Requirements; @@ -187,7 +181,6 @@ export function evaluateRequirementsFromMetadata(params: { localPlatform: params.localPlatform, remotePlatforms: params.remotePlatforms, isEnvSatisfied: params.isEnvSatisfied, - resolveConfigValue: params.resolveConfigValue, isConfigSatisfied: params.isConfigSatisfied, }); return { required, ...result }; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 1c85b87319..b1114386c0 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -710,7 +710,6 @@ export type CronRunLogEntry = { export type SkillsStatusConfigCheck = { path: string; - value: unknown; satisfied: boolean; };