From ab126825efd56b180227899dd4d31d74b0d47358 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 16 May 2026 18:44:25 +0100 Subject: [PATCH] feat(security): commandsSyncKey is a required channel-config field (Guild C-2) The slash-command sync secret now comes from channels.fabric.commandsSyncKey (configSchema marks it required) and is no longer read from FABRIC_COMMANDS_SYNC_KEY env. command-sync resolves it from config and threads it into client.syncCommands; when absent, sync is skipped with a clear warning. README updated. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 ++++ dist/fabric/src/accounts.js | 6 ++++++ dist/fabric/src/command-sync.js | 11 ++++++++++- dist/fabric/src/fabric-client.js | 11 +++++------ openclaw.plugin.json | 11 +++++++++-- src/accounts.ts | 11 +++++++++++ src/command-sync.ts | 14 +++++++++++++- src/fabric-client.ts | 10 +++++----- 8 files changed, 63 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 59828d3..de641d0 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,10 @@ Two ways, both write the same identity registry the transport reads: ## Config - `channels.fabric.centerApiBase` — e.g. `http://localhost:7001/api` +- `channels.fabric.commandsSyncKey` — **required**; must equal the guild's + `FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY` (Guild C-2). Read it from the + guild with `docker exec fabric-backend-guild node dist/cli/print-commands-sync-key.js`. + Sourced from config only — never from the environment. - `channels.fabric.accounts.` = `{ fabricApiKey, enabled }` (**agent = account**; the account id is the OpenClaw agentId) - plugin `identityFilePath` — default `~/.openclaw/fabric-identity.json` diff --git a/dist/fabric/src/accounts.js b/dist/fabric/src/accounts.js index 3852348..d60f1a9 100644 --- a/dist/fabric/src/accounts.js +++ b/dist/fabric/src/accounts.js @@ -7,6 +7,12 @@ const DEFAULT_CENTER = 'http://localhost:7001/api'; function section(cfg) { return cfg.channels?.fabric ?? {}; } +// The commands-sync shared secret (channel-level only). Empty string when +// unconfigured — callers decide how to handle (slash-command sync is then +// rejected by the guild). +export function resolveCommandsSyncKey(cfg) { + return (section(cfg).commandsSyncKey ?? '').trim(); +} export function listFabricAccountIds(cfg) { const accts = section(cfg).accounts ?? {}; const ids = Object.keys(accts); diff --git a/dist/fabric/src/command-sync.js b/dist/fabric/src/command-sync.js index 54b5b6c..ec597f0 100644 --- a/dist/fabric/src/command-sync.js +++ b/dist/fabric/src/command-sync.js @@ -6,6 +6,7 @@ // dynamic arg `choices` to a static snapshot here (like Discord does at // registration time). import { listNativeCommandSpecsForConfig, findCommandByNativeName, resolveCommandArgChoices, } from 'openclaw/plugin-sdk/native-command-registry'; +import { resolveCommandsSyncKey } from './accounts.js'; function normChoice(c) { if (typeof c === 'string') return { value: c, label: c }; @@ -62,6 +63,14 @@ export function buildFabricCommandSpecs(cfg) { // Push the catalog to every guild the known agents belong to (idempotent; // the catalog is OpenClaw-global, so one PUT per guild is enough). export async function syncFabricCommands(client, cfg, accounts, log) { + // Guild C-2: the sync key comes from the channel config only (schema + // marks it required). Without it the guild rejects the catalog write. + const syncKey = resolveCommandsSyncKey(cfg); + if (!syncKey) { + log.warn('fabric: channels.fabric.commandsSyncKey is not set — skipping ' + + 'slash-command sync (set it to the guild FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY)'); + return; + } let specs; try { specs = buildFabricCommandSpecs(cfg); @@ -88,7 +97,7 @@ export async function syncFabricCommands(client, cfg, accounts, log) { if (!tok) continue; try { - await client.syncCommands(g.endpoint, tok, specs); + await client.syncCommands(g.endpoint, tok, specs, syncKey); done.add(g.nodeId); log.info(`fabric: synced ${specs.length} slash command(s) -> ${g.nodeId}`); } diff --git a/dist/fabric/src/fabric-client.js b/dist/fabric/src/fabric-client.js index e65c59e..35ef0c0 100644 --- a/dist/fabric/src/fabric-client.js +++ b/dist/fabric/src/fabric-client.js @@ -75,12 +75,11 @@ export class FabricClient { // Register the OpenClaw slash-command catalog with this guild (idempotent // full replace). The frontend GETs it for `/` autocomplete; execution // still flows as a normal / message into OpenClaw's command system. - syncCommands(guildEndpoint, guildToken, commands) { - // Guild C-2: when the operator sets a shared sync key on both sides - // (FABRIC_COMMANDS_SYNC_KEY here / FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY - // on the guild), the catalog write is restricted to this plugin. - const key = process.env.FABRIC_COMMANDS_SYNC_KEY; - return this.req('PUT', `${guildEndpoint}/api/commands`, guildToken, { commands }, key ? { 'x-commands-sync-key': key } : undefined); + syncCommands(guildEndpoint, guildToken, commands, syncKey) { + // Guild C-2: the shared key is sourced from the channel config + // (channels.fabric.commandsSyncKey) and must equal the guild's + // FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY for the catalog write. + return this.req('PUT', `${guildEndpoint}/api/commands`, guildToken, { commands }, syncKey ? { 'x-commands-sync-key': syncKey } : undefined); } // [{ userId, bypass }] — bypass is true only for discuss/work bypass-list channelMembers(guildEndpoint, guildToken, channelId) { diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 43edf54..6841548 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -39,6 +39,11 @@ "type": "string", "description": "Fabric Center API base, e.g. http://localhost:7001/api" }, + "commandsSyncKey": { + "type": "string", + "minLength": 1, + "description": "Shared secret that must equal the guild's FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY. Required to register the slash-command catalog (Guild C-2). Read it from the guild via: docker exec fabric-backend-guild node dist/cli/print-commands-sync-key.js" + }, "dmSecurity": { "type": "string" }, "dmPolicy": { "type": "string" }, "enabled": { "type": "boolean" }, @@ -59,10 +64,12 @@ } } } - } + }, + "required": ["commandsSyncKey"] }, "uiHints": { - "centerApiBase": { "label": "Center API base" } + "centerApiBase": { "label": "Center API base" }, + "commandsSyncKey": { "label": "Commands sync key" } } } } diff --git a/src/accounts.ts b/src/accounts.ts index d5f2f96..c532673 100644 --- a/src/accounts.ts +++ b/src/accounts.ts @@ -14,6 +14,10 @@ export type FabricAccountConfig = { export type FabricChannelConfig = { centerApiBase?: string; + // Shared secret matching the guild's FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY + // (Guild C-2). Required by the channel config schema; sourced from config + // only — never from the environment. + commandsSyncKey?: string; accounts?: Record; defaultAccount?: string; } & FabricAccountConfig; @@ -35,6 +39,13 @@ function section(cfg: Cfg): FabricChannelConfig { return cfg.channels?.fabric ?? {}; } +// The commands-sync shared secret (channel-level only). Empty string when +// unconfigured — callers decide how to handle (slash-command sync is then +// rejected by the guild). +export function resolveCommandsSyncKey(cfg: Cfg): string { + return (section(cfg).commandsSyncKey ?? '').trim(); +} + export function listFabricAccountIds(cfg: Cfg): string[] { const accts = section(cfg).accounts ?? {}; const ids = Object.keys(accts); diff --git a/src/command-sync.ts b/src/command-sync.ts index 8017a63..30d18ea 100644 --- a/src/command-sync.ts +++ b/src/command-sync.ts @@ -11,6 +11,7 @@ import { resolveCommandArgChoices, } from 'openclaw/plugin-sdk/native-command-registry'; import type { FabricClient } from './fabric-client.js'; +import { resolveCommandsSyncKey } from './accounts.js'; type Logger = { info: (m: string) => void; warn: (m: string) => void }; @@ -102,6 +103,17 @@ export async function syncFabricCommands( accounts: Array<{ agentId: string; fabricApiKey: string }>, log: Logger, ): Promise { + // Guild C-2: the sync key comes from the channel config only (schema + // marks it required). Without it the guild rejects the catalog write. + const syncKey = resolveCommandsSyncKey(cfg as never); + if (!syncKey) { + log.warn( + 'fabric: channels.fabric.commandsSyncKey is not set — skipping ' + + 'slash-command sync (set it to the guild FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY)', + ); + return; + } + let specs: FabricCommand[]; try { specs = buildFabricCommandSpecs(cfg); @@ -126,7 +138,7 @@ export async function syncFabricCommands( )?.token; if (!tok) continue; try { - await client.syncCommands(g.endpoint, tok, specs); + await client.syncCommands(g.endpoint, tok, specs, syncKey); done.add(g.nodeId); log.info(`fabric: synced ${specs.length} slash command(s) -> ${g.nodeId}`); } catch (err) { diff --git a/src/fabric-client.ts b/src/fabric-client.ts index 486335a..314f3de 100644 --- a/src/fabric-client.ts +++ b/src/fabric-client.ts @@ -124,17 +124,17 @@ export class FabricClient { guildEndpoint: string, guildToken: string, commands: unknown[], + syncKey: string, ): Promise { - // Guild C-2: when the operator sets a shared sync key on both sides - // (FABRIC_COMMANDS_SYNC_KEY here / FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY - // on the guild), the catalog write is restricted to this plugin. - const key = process.env.FABRIC_COMMANDS_SYNC_KEY; + // Guild C-2: the shared key is sourced from the channel config + // (channels.fabric.commandsSyncKey) and must equal the guild's + // FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY for the catalog write. return this.req( 'PUT', `${guildEndpoint}/api/commands`, guildToken, { commands }, - key ? { 'x-commands-sync-key': key } : undefined, + syncKey ? { 'x-commands-sync-key': syncKey } : undefined, ); }