feat(tools): gate registerTool via __padded.allowTool

Wires Fabric into the PaddedCell tools-cache filter (decision #37,
openclaw side). Each registerTool call is now wrapped to:

  1. Register name+description into PaddedCell's catalog so
     dynamic-list-tools / dynamic-search-tools surface the tool
  2. Return null when the per-session cache doesn't include the name
     (the model doesn't see it that turn)

Fail-open stub installed if PaddedCell hasn't loaded yet — the stub
queues catalog entries on `_pendingCatalog`, which PaddedCell drains
when it initializes. All 13 Fabric tools (fabric-register, 4 channel
creates, sub-discussion control, send/canvas, channel/guild list,
purpose, message-history) gated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-06-05 15:39:06 +01:00
parent fc2ab628b2
commit 40c9cb5740

View File

@@ -35,6 +35,30 @@ const GREETING_DELAY_MS = (() => {
return Number.isFinite(v) && v >= 0 ? v : 500; return Number.isFinite(v) && v >= 0 ? v : 500;
})(); })();
// PaddedCell tools-cache integration (cross-runtime alignment with
// Plexum decision #37; openclaw lacks a before_outgoing_tools hook so we
// opt in per-plugin). All Fabric tools are gate-able by default — agents
// must `dynamic-cache-tools` them before the model sees them.
// `gatedRegister` wraps openclaw's api.registerTool: each factory invocation
// (1) registers the tool's name+description into PaddedCell's catalog
// so dynamic-list-tools / dynamic-search-tools surface it
// (2) returns null if the per-session cache doesn't include the name
// Buffer-drain pattern handles plugin load order — if PaddedCell hasn't
// loaded yet, the stub default-allows + queues catalog entries; PaddedCell
// drains the queue when it installs the real API.
function ensurePaddedStub(): void {
const g = globalThis as unknown as { __padded?: Record<string, unknown> & { _pendingCatalog?: Array<{ name: string; description: string }>; allowTool?: unknown; registerCatalogEntry?: unknown } };
if (g.__padded) return;
const buf: Array<{ name: string; description: string }> = [];
g.__padded = {
_pendingCatalog: buf,
registerCatalogEntry(name: string, description: string): void {
buf.push({ name, description });
},
allowTool: () => true, // fail-open until PaddedCell installs the real API
};
}
export function registerFabricTools( export function registerFabricTools(
api: ToolApi, api: ToolApi,
client: FabricClient, client: FabricClient,
@@ -42,6 +66,21 @@ export function registerFabricTools(
store: SubDiscussionStore, store: SubDiscussionStore,
cfg: ToolsCfg, cfg: ToolsCfg,
): void { ): void {
ensurePaddedStub();
const seenForCatalog = new Set<string>();
const gatedRegister = (factory: (ctx: Ctx) => unknown | null): void => {
api.registerTool((ctx: Ctx) => {
const tool = factory(ctx) as { name?: string; description?: string } | null;
if (!tool || !tool.name) return tool;
const padded = (globalThis as unknown as { __padded?: { allowTool?: (n: string, c: Ctx) => boolean; registerCatalogEntry?: (n: string, d: string) => void } }).__padded;
if (padded?.registerCatalogEntry && !seenForCatalog.has(tool.name)) {
padded.registerCatalogEntry(tool.name, tool.description ?? '');
seenForCatalog.add(tool.name);
}
if (padded?.allowTool && !padded.allowTool(tool.name, ctx)) return null;
return tool;
});
};
// Resolve the calling agent's Fabric session + a guild's token/endpoint. // Resolve the calling agent's Fabric session + a guild's token/endpoint.
const ctxGuild = async (agentId: string, guildNodeId: string) => { const ctxGuild = async (agentId: string, guildNodeId: string) => {
const entry = identity.findByAgentId(agentId); const entry = identity.findByAgentId(agentId);
@@ -65,7 +104,7 @@ export function registerFabricTools(
// still exists for one-time bootstrap before the gateway runs, but // still exists for one-time bootstrap before the gateway runs, but
// recruitment's `register-agent` script should prefer this tool path // recruitment's `register-agent` script should prefer this tool path
// so the new agent's socket is live before `interviewer` fires. // so the new agent's socket is live before `interviewer` fires.
api.registerTool((ctx: Ctx) => ({ gatedRegister((ctx: Ctx) => ({
name: 'fabric-register', name: 'fabric-register',
description: description:
'Bind an agent to a Fabric Center API key. Validates the key, writes ' + 'Bind an agent to a Fabric Center API key. Validates the key, writes ' +
@@ -128,7 +167,7 @@ export function registerFabricTools(
})); }));
const makeCreate = (kind: 'chat' | 'work' | 'report' | 'discussion') => const makeCreate = (kind: 'chat' | 'work' | 'report' | 'discussion') =>
api.registerTool((ctx: Ctx) => ({ gatedRegister((ctx: Ctx) => ({
name: `create-${kind}-channel`, name: `create-${kind}-channel`,
description: description:
`Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}). ` + `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}). ` +
@@ -184,7 +223,7 @@ export function registerFabricTools(
// discussion-complete: post a summary then close the channel (Guild // discussion-complete: post a summary then close the channel (Guild
// /channels/:id/close — history stays readable, new posts -> 409). // /channels/:id/close — history stays readable, new posts -> 409).
api.registerTool((ctx: Ctx) => ({ gatedRegister((ctx: Ctx) => ({
name: 'discussion-complete', name: 'discussion-complete',
description: 'Conclude a discussion: post a summary then close the channel.', description: 'Conclude a discussion: post a summary then close the channel.',
parameters: { parameters: {
@@ -242,7 +281,7 @@ export function registerFabricTools(
// session prompt whenever a turn in this channel fires — so the // session prompt whenever a turn in this channel fires — so the
// two roles see different instructions, no shared guide file. // two roles see different instructions, no shared guide file.
// ─────────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────────
api.registerTool((ctx: Ctx) => ({ gatedRegister((ctx: Ctx) => ({
name: 'create-sub-discussion', name: 'create-sub-discussion',
description: description:
'Open a host-driven sub-discussion channel (x_type=discuss) hanging off your current channel, ' + 'Open a host-driven sub-discussion channel (x_type=discuss) hanging off your current channel, ' +
@@ -363,7 +402,7 @@ export function registerFabricTools(
// author, not the host's personal account — and can wake the host on // author, not the host's personal account — and can wake the host on
// the parent channel to continue whatever workflow opened the sub. // the parent channel to continue whatever workflow opened the sub.
// ─────────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────────
api.registerTool((ctx: Ctx) => ({ gatedRegister((ctx: Ctx) => ({
name: 'close-sub-discussion', name: 'close-sub-discussion',
description: description:
'Close a sub-discussion channel you opened (host-only) and write a callback to the parent ' + 'Close a sub-discussion channel you opened (host-only) and write a callback to the parent ' +
@@ -460,7 +499,7 @@ export function registerFabricTools(
// fabric-canvas: share / update / read / close the channel's single // fabric-canvas: share / update / read / close the channel's single
// pinned canvas document (one tool, four actions). update/close are // pinned canvas document (one tool, four actions). update/close are
// sharer-only server-side (the guild returns 403 otherwise). // sharer-only server-side (the guild returns 403 otherwise).
api.registerTool((ctx: Ctx) => ({ gatedRegister((ctx: Ctx) => ({
name: 'fabric-canvas', name: 'fabric-canvas',
description: description:
"Manage a channel's pinned canvas document. action: " + "Manage a channel's pinned canvas document. action: " +
@@ -537,7 +576,7 @@ export function registerFabricTools(
})); }));
// fabric-channel: channel membership (one tool, three actions). // fabric-channel: channel membership (one tool, three actions).
api.registerTool((ctx: Ctx) => ({ gatedRegister((ctx: Ctx) => ({
name: 'fabric-channel', name: 'fabric-channel',
description: description:
'Channel membership. action: members (list channel member userIds) | ' + 'Channel membership. action: members (list channel member userIds) | ' +
@@ -589,7 +628,7 @@ export function registerFabricTools(
// workload to #agents-room, or triage agent following up on an // workload to #agents-room, or triage agent following up on an
// already-routed task by commenting in #updates. // already-routed task by commenting in #updates.
// ----------------------------------------------------------------- // -----------------------------------------------------------------
api.registerTool((ctx: Ctx) => ({ gatedRegister((ctx: Ctx) => ({
name: 'fabric-send-message', name: 'fabric-send-message',
description: description:
'Send a text message into a specific Fabric channel. Author is the calling agent. ' + 'Send a text message into a specific Fabric channel. Author is the calling agent. ' +
@@ -634,7 +673,7 @@ export function registerFabricTools(
// config → tool returns ok:false with a clear error, no fall-through // config → tool returns ok:false with a clear error, no fall-through
// to regular agent posting. // to regular agent posting.
// ─────────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────────
api.registerTool((ctx: Ctx) => ({ gatedRegister((ctx: Ctx) => ({
name: 'fabric-send-sys-msg', name: 'fabric-send-sys-msg',
description: description:
'Send a SYSTEM-AUTHORED message into a Fabric channel (author = guild sentinel, not you). ' + 'Send a SYSTEM-AUTHORED message into a Fabric channel (author = guild sentinel, not you). ' +
@@ -708,7 +747,7 @@ export function registerFabricTools(
// agent is a member of. Returns id / name / xType per channel so the // agent is a member of. Returns id / name / xType per channel so the
// agent can pick a channelId for fabric-send-message etc. // agent can pick a channelId for fabric-send-message etc.
// ----------------------------------------------------------------- // -----------------------------------------------------------------
api.registerTool((ctx: Ctx) => ({ gatedRegister((ctx: Ctx) => ({
name: 'fabric-channel-list', name: 'fabric-channel-list',
description: description:
'List channels visible to the calling agent in a guild. Optional ' + 'List channels visible to the calling agent in a guild. Optional ' +
@@ -769,7 +808,7 @@ export function registerFabricTools(
// workflow says "find the right guild for X" — pick by purpose, // workflow says "find the right guild for X" — pick by purpose,
// then fabric-channel-list to find the right channel inside it. // then fabric-channel-list to find the right channel inside it.
// ----------------------------------------------------------------- // -----------------------------------------------------------------
api.registerTool((ctx: Ctx) => ({ gatedRegister((ctx: Ctx) => ({
name: 'fabric-guild-list', name: 'fabric-guild-list',
description: description:
'List guilds the calling agent is a member of. Returns ' + 'List guilds the calling agent is a member of. Returns ' +
@@ -830,7 +869,7 @@ export function registerFabricTools(
// channel must be public). Use this to backfill purpose on existing // channel must be public). Use this to backfill purpose on existing
// channels, or to refine it after a channel's role evolves. // channels, or to refine it after a channel's role evolves.
// ----------------------------------------------------------------- // -----------------------------------------------------------------
api.registerTool((ctx: Ctx) => ({ gatedRegister((ctx: Ctx) => ({
name: 'fabric-channel-set-purpose', name: 'fabric-channel-set-purpose',
description: description:
"Set or update a channel's free-form purpose description. " + "Set or update a channel's free-form purpose description. " +
@@ -873,7 +912,7 @@ export function registerFabricTools(
// gated; verify a previous message went through; lookup recent // gated; verify a previous message went through; lookup recent
// duplicates before opening a new task in triage. // duplicates before opening a new task in triage.
// ----------------------------------------------------------------- // -----------------------------------------------------------------
api.registerTool((ctx: Ctx) => ({ gatedRegister((ctx: Ctx) => ({
name: 'fabric-message-history', name: 'fabric-message-history',
description: description:
"Read a channel's recent message history. Omit seqFrom/seqTo to " + "Read a channel's recent message history. Omit seqFrom/seqTo to " +