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) <noreply@anthropic.com>
This commit is contained in:
@@ -68,6 +68,10 @@ Two ways, both write the same identity registry the transport reads:
|
|||||||
## Config
|
## Config
|
||||||
|
|
||||||
- `channels.fabric.centerApiBase` — e.g. `http://localhost:7001/api`
|
- `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.<agentId>` = `{ fabricApiKey, enabled }`
|
- `channels.fabric.accounts.<agentId>` = `{ fabricApiKey, enabled }`
|
||||||
(**agent = account**; the account id is the OpenClaw agentId)
|
(**agent = account**; the account id is the OpenClaw agentId)
|
||||||
- plugin `identityFilePath` — default `~/.openclaw/fabric-identity.json`
|
- plugin `identityFilePath` — default `~/.openclaw/fabric-identity.json`
|
||||||
|
|||||||
6
dist/fabric/src/accounts.js
vendored
6
dist/fabric/src/accounts.js
vendored
@@ -7,6 +7,12 @@ const DEFAULT_CENTER = 'http://localhost:7001/api';
|
|||||||
function section(cfg) {
|
function section(cfg) {
|
||||||
return cfg.channels?.fabric ?? {};
|
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) {
|
export function listFabricAccountIds(cfg) {
|
||||||
const accts = section(cfg).accounts ?? {};
|
const accts = section(cfg).accounts ?? {};
|
||||||
const ids = Object.keys(accts);
|
const ids = Object.keys(accts);
|
||||||
|
|||||||
11
dist/fabric/src/command-sync.js
vendored
11
dist/fabric/src/command-sync.js
vendored
@@ -6,6 +6,7 @@
|
|||||||
// dynamic arg `choices` to a static snapshot here (like Discord does at
|
// dynamic arg `choices` to a static snapshot here (like Discord does at
|
||||||
// registration time).
|
// registration time).
|
||||||
import { listNativeCommandSpecsForConfig, findCommandByNativeName, resolveCommandArgChoices, } from 'openclaw/plugin-sdk/native-command-registry';
|
import { listNativeCommandSpecsForConfig, findCommandByNativeName, resolveCommandArgChoices, } from 'openclaw/plugin-sdk/native-command-registry';
|
||||||
|
import { resolveCommandsSyncKey } from './accounts.js';
|
||||||
function normChoice(c) {
|
function normChoice(c) {
|
||||||
if (typeof c === 'string')
|
if (typeof c === 'string')
|
||||||
return { value: c, label: c };
|
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;
|
// Push the catalog to every guild the known agents belong to (idempotent;
|
||||||
// the catalog is OpenClaw-global, so one PUT per guild is enough).
|
// the catalog is OpenClaw-global, so one PUT per guild is enough).
|
||||||
export async function syncFabricCommands(client, cfg, accounts, log) {
|
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;
|
let specs;
|
||||||
try {
|
try {
|
||||||
specs = buildFabricCommandSpecs(cfg);
|
specs = buildFabricCommandSpecs(cfg);
|
||||||
@@ -88,7 +97,7 @@ export async function syncFabricCommands(client, cfg, accounts, log) {
|
|||||||
if (!tok)
|
if (!tok)
|
||||||
continue;
|
continue;
|
||||||
try {
|
try {
|
||||||
await client.syncCommands(g.endpoint, tok, specs);
|
await client.syncCommands(g.endpoint, tok, specs, syncKey);
|
||||||
done.add(g.nodeId);
|
done.add(g.nodeId);
|
||||||
log.info(`fabric: synced ${specs.length} slash command(s) -> ${g.nodeId}`);
|
log.info(`fabric: synced ${specs.length} slash command(s) -> ${g.nodeId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
11
dist/fabric/src/fabric-client.js
vendored
11
dist/fabric/src/fabric-client.js
vendored
@@ -75,12 +75,11 @@ export class FabricClient {
|
|||||||
// Register the OpenClaw slash-command catalog with this guild (idempotent
|
// Register the OpenClaw slash-command catalog with this guild (idempotent
|
||||||
// full replace). The frontend GETs it for `/` autocomplete; execution
|
// full replace). The frontend GETs it for `/` autocomplete; execution
|
||||||
// still flows as a normal /<cmd> message into OpenClaw's command system.
|
// still flows as a normal /<cmd> message into OpenClaw's command system.
|
||||||
syncCommands(guildEndpoint, guildToken, commands) {
|
syncCommands(guildEndpoint, guildToken, commands, syncKey) {
|
||||||
// Guild C-2: when the operator sets a shared sync key on both sides
|
// Guild C-2: the shared key is sourced from the channel config
|
||||||
// (FABRIC_COMMANDS_SYNC_KEY here / FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY
|
// (channels.fabric.commandsSyncKey) and must equal the guild's
|
||||||
// on the guild), the catalog write is restricted to this plugin.
|
// FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY for the catalog write.
|
||||||
const key = process.env.FABRIC_COMMANDS_SYNC_KEY;
|
return this.req('PUT', `${guildEndpoint}/api/commands`, guildToken, { commands }, syncKey ? { 'x-commands-sync-key': syncKey } : undefined);
|
||||||
return this.req('PUT', `${guildEndpoint}/api/commands`, guildToken, { commands }, key ? { 'x-commands-sync-key': key } : undefined);
|
|
||||||
}
|
}
|
||||||
// [{ userId, bypass }] — bypass is true only for discuss/work bypass-list
|
// [{ userId, bypass }] — bypass is true only for discuss/work bypass-list
|
||||||
channelMembers(guildEndpoint, guildToken, channelId) {
|
channelMembers(guildEndpoint, guildToken, channelId) {
|
||||||
|
|||||||
@@ -39,6 +39,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Fabric Center API base, e.g. http://localhost:7001/api"
|
"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" },
|
"dmSecurity": { "type": "string" },
|
||||||
"dmPolicy": { "type": "string" },
|
"dmPolicy": { "type": "string" },
|
||||||
"enabled": { "type": "boolean" },
|
"enabled": { "type": "boolean" },
|
||||||
@@ -59,10 +64,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"required": ["commandsSyncKey"]
|
||||||
},
|
},
|
||||||
"uiHints": {
|
"uiHints": {
|
||||||
"centerApiBase": { "label": "Center API base" }
|
"centerApiBase": { "label": "Center API base" },
|
||||||
|
"commandsSyncKey": { "label": "Commands sync key" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export type FabricAccountConfig = {
|
|||||||
|
|
||||||
export type FabricChannelConfig = {
|
export type FabricChannelConfig = {
|
||||||
centerApiBase?: string;
|
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<string, FabricAccountConfig>;
|
accounts?: Record<string, FabricAccountConfig>;
|
||||||
defaultAccount?: string;
|
defaultAccount?: string;
|
||||||
} & FabricAccountConfig;
|
} & FabricAccountConfig;
|
||||||
@@ -35,6 +39,13 @@ function section(cfg: Cfg): FabricChannelConfig {
|
|||||||
return cfg.channels?.fabric ?? {};
|
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[] {
|
export function listFabricAccountIds(cfg: Cfg): string[] {
|
||||||
const accts = section(cfg).accounts ?? {};
|
const accts = section(cfg).accounts ?? {};
|
||||||
const ids = Object.keys(accts);
|
const ids = Object.keys(accts);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
resolveCommandArgChoices,
|
resolveCommandArgChoices,
|
||||||
} from 'openclaw/plugin-sdk/native-command-registry';
|
} from 'openclaw/plugin-sdk/native-command-registry';
|
||||||
import type { FabricClient } from './fabric-client.js';
|
import type { FabricClient } from './fabric-client.js';
|
||||||
|
import { resolveCommandsSyncKey } from './accounts.js';
|
||||||
|
|
||||||
type Logger = { info: (m: string) => void; warn: (m: string) => void };
|
type Logger = { info: (m: string) => void; warn: (m: string) => void };
|
||||||
|
|
||||||
@@ -102,6 +103,17 @@ export async function syncFabricCommands(
|
|||||||
accounts: Array<{ agentId: string; fabricApiKey: string }>,
|
accounts: Array<{ agentId: string; fabricApiKey: string }>,
|
||||||
log: Logger,
|
log: Logger,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// 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[];
|
let specs: FabricCommand[];
|
||||||
try {
|
try {
|
||||||
specs = buildFabricCommandSpecs(cfg);
|
specs = buildFabricCommandSpecs(cfg);
|
||||||
@@ -126,7 +138,7 @@ export async function syncFabricCommands(
|
|||||||
)?.token;
|
)?.token;
|
||||||
if (!tok) continue;
|
if (!tok) continue;
|
||||||
try {
|
try {
|
||||||
await client.syncCommands(g.endpoint, tok, specs);
|
await client.syncCommands(g.endpoint, tok, specs, syncKey);
|
||||||
done.add(g.nodeId);
|
done.add(g.nodeId);
|
||||||
log.info(`fabric: synced ${specs.length} slash command(s) -> ${g.nodeId}`);
|
log.info(`fabric: synced ${specs.length} slash command(s) -> ${g.nodeId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -124,17 +124,17 @@ export class FabricClient {
|
|||||||
guildEndpoint: string,
|
guildEndpoint: string,
|
||||||
guildToken: string,
|
guildToken: string,
|
||||||
commands: unknown[],
|
commands: unknown[],
|
||||||
|
syncKey: string,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
// Guild C-2: when the operator sets a shared sync key on both sides
|
// Guild C-2: the shared key is sourced from the channel config
|
||||||
// (FABRIC_COMMANDS_SYNC_KEY here / FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY
|
// (channels.fabric.commandsSyncKey) and must equal the guild's
|
||||||
// on the guild), the catalog write is restricted to this plugin.
|
// FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY for the catalog write.
|
||||||
const key = process.env.FABRIC_COMMANDS_SYNC_KEY;
|
|
||||||
return this.req(
|
return this.req(
|
||||||
'PUT',
|
'PUT',
|
||||||
`${guildEndpoint}/api/commands`,
|
`${guildEndpoint}/api/commands`,
|
||||||
guildToken,
|
guildToken,
|
||||||
{ commands },
|
{ commands },
|
||||||
key ? { 'x-commands-sync-key': key } : undefined,
|
syncKey ? { 'x-commands-sync-key': syncKey } : undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user