feat(plugin): fabric-canvas tool; fabric-register env=AGENT_ID only

- bin/fabric-register.mjs: only AGENT_ID is read from the environment;
  --api-key is flag-only (no FABRIC_API_KEY); dropped FABRIC_CENTER_API_BASE
  / FABRIC_IDENTITY_FILE / OPENCLAW_PATH env fallbacks (flags + sensible
  defaults; --center still falls back to openclaw.json).
- New fabric-canvas tool (one tool, four actions): read / share / update /
  close the channel's single pinned canvas. Backed by FabricClient
  get/share/update/removeCanvas (GET/PUT/PATCH/DELETE; empty 2xx body ->
  null). update/close are sharer-only server-side.
- README updated.

Verified: client-level smoke against the running guild —
read(empty→null) → share(v1) → read → update(v2) → close(→null) all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-16 15:28:13 +01:00
parent 26c12533fb
commit aaabb0ddb0
6 changed files with 299 additions and 28 deletions

View File

@@ -59,10 +59,11 @@ Two ways, both write the same identity registry the transport reads:
It validates the key against Center, then writes It validates the key against Center, then writes
`~/.openclaw/fabric-identity.json`. One-time and persistent — *not* per `~/.openclaw/fabric-identity.json`. One-time and persistent — *not* per
login; the plugin's transport logs in and stays connected on its own. login; the plugin's transport logs in and stays connected on its own.
`AGENT_ID` env wins; otherwise `--agent-id` is required. Other flags: **Only `AGENT_ID` is read from the environment** — if unset, `--agent-id`
`--center`, `--identity-file`, `--openclaw-path` (env equivalents: is required. `--api-key` is flag-only (never from the env). Other flags:
`FABRIC_API_KEY`, `FABRIC_CENTER_API_BASE`, `FABRIC_IDENTITY_FILE`, `--center`, `--identity-file`, `--openclaw-path` (sensible defaults;
`OPENCLAW_PATH`). Restart the gateway afterwards. `--center` also falls back to `openclaw.json`). Restart the gateway
afterwards.
## Config ## Config
@@ -90,6 +91,11 @@ Then `openclaw gateway restart`.
`create-report-channel` (report) / `create-discussion-channel` (discuss) `create-report-channel` (report) / `create-discussion-channel` (discuss)
- `discussion-complete` — post a summary, then close the channel - `discussion-complete` — post a summary, then close the channel
(closed → history readable; new posts → `409`) (closed → history readable; new posts → `409`)
- `fabric-canvas` — manage a channel's single pinned canvas document; one
tool, four `action`s: `read` (current canvas or null) · `share`
(create/replace; caller becomes sharer) · `update` (edit in place;
sharer-only) · `close` (remove; sharer-only). `share` needs
`title`/`format`(`md`|`html`|`text`)/`source`.
## Install / build ## Install / build

View File

@@ -14,14 +14,15 @@
* fabric-register --api-key fak_xxx # uses $AGENT_ID * fabric-register --api-key fak_xxx # uses $AGENT_ID
* fabric-register --agent-id echo --api-key fak_xxx * fabric-register --agent-id echo --api-key fak_xxx
* *
* Flags / env: * Only AGENT_ID is read from the environment; everything else is a flag.
* --agent-id <id> (or env AGENT_ID; one of them required) *
* --api-key <fak_…> (or env FABRIC_API_KEY; required) * Flags:
* --center <url> (or env FABRIC_CENTER_API_BASE; else openclaw.json; * --agent-id <id> required unless the AGENT_ID env var is set
* else http://localhost:7001/api) * --api-key <fak_…> required (flag only — never from the environment)
* --identity-file <path> (or env FABRIC_IDENTITY_FILE; else * --center <url> else openclaw.json channels.fabric.centerApiBase;
* ~/.openclaw/fabric-identity.json) * else http://localhost:7001/api
* --openclaw-path <dir> (or env OPENCLAW_PATH; else ~/.openclaw) * --identity-file <path> default ~/.openclaw/fabric-identity.json
* --openclaw-path <dir> default ~/.openclaw
* -h | --help * -h | --help
*/ */
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
@@ -51,14 +52,16 @@ const HELP = `fabric-register — bind an OpenClaw agent to a Fabric Center API
fabric-register --api-key fak_xxx # agent id from $AGENT_ID fabric-register --api-key fak_xxx # agent id from $AGENT_ID
fabric-register --agent-id <id> --api-key fak_xxx fabric-register --agent-id <id> --api-key fak_xxx
--agent-id <id> required unless $AGENT_ID is set --agent-id <id> required unless the AGENT_ID env var is set
--api-key <fak_…> required (or env FABRIC_API_KEY) --api-key <fak_…> required (flag only — never read from the env)
--center <url> Center API base (or env FABRIC_CENTER_API_BASE; --center <url> Center API base (else openclaw.json
else openclaw.json channels.fabric.centerApiBase; channels.fabric.centerApiBase; else
else http://localhost:7001/api) http://localhost:7001/api)
--identity-file <path> default ~/.openclaw/fabric-identity.json --identity-file <path> default ~/.openclaw/fabric-identity.json
--openclaw-path <dir> default ~/.openclaw --openclaw-path <dir> default ~/.openclaw
-h, --help -h, --help
Only AGENT_ID is taken from the environment; everything else is a flag.
`; `;
function fail(msg) { function fail(msg) {
@@ -81,22 +84,17 @@ async function main() {
fail('no agent id: set the AGENT_ID environment variable or pass --agent-id <id>'); fail('no agent id: set the AGENT_ID environment variable or pass --agent-id <id>');
} }
const apiKey = // api key: flag ONLY — never from the environment.
(typeof a['api-key'] === 'string' && a['api-key'].trim()) || const apiKey = typeof a['api-key'] === 'string' && a['api-key'].trim();
(process.env.FABRIC_API_KEY && process.env.FABRIC_API_KEY.trim()); if (!apiKey) fail('missing --api-key <fak_…> (flag only)');
if (!apiKey) fail('missing --api-key <fak_…> (or env FABRIC_API_KEY)');
const openclawPath = resolve( const openclawPath = resolve(
(typeof a['openclaw-path'] === 'string' && a['openclaw-path']) || (typeof a['openclaw-path'] === 'string' && a['openclaw-path']) ||
process.env.OPENCLAW_PATH ||
join(homedir(), '.openclaw'), join(homedir(), '.openclaw'),
); );
// center api base: flag > env > openclaw.json > default // center api base: flag > openclaw.json > default
let center = let center = (typeof a.center === 'string' && a.center) || '';
(typeof a.center === 'string' && a.center) ||
process.env.FABRIC_CENTER_API_BASE ||
'';
if (!center) { if (!center) {
try { try {
const cfg = JSON.parse(readFileSync(join(openclawPath, 'openclaw.json'), 'utf8')); const cfg = JSON.parse(readFileSync(join(openclawPath, 'openclaw.json'), 'utf8'));
@@ -110,7 +108,6 @@ async function main() {
const identityFile = resolve( const identityFile = resolve(
(typeof a['identity-file'] === 'string' && a['identity-file']) || (typeof a['identity-file'] === 'string' && a['identity-file']) ||
process.env.FABRIC_IDENTITY_FILE ||
join(openclawPath, 'fabric-identity.json'), join(openclawPath, 'fabric-identity.json'),
); );

View File

@@ -21,6 +21,24 @@ export class FabricClient {
} }
return (await res.json()); return (await res.json());
} }
// Generic JSON request (GET/PUT/PATCH/DELETE). Empty 2xx body -> null
// (Fabric returns an empty body when a channel has no canvas).
async req(method, url, auth, body) {
const res = await fetch(url, {
method,
headers: {
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
...(auth ? { authorization: `Bearer ${auth}` } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${method} ${url} -> ${res.status} ${text}`);
}
const text = await res.text();
return (text ? JSON.parse(text) : null);
}
// Exchange an agent API key for a Fabric user session (+ guild tokens). // Exchange an agent API key for a Fabric user session (+ guild tokens).
agentLogin(apiKey) { agentLogin(apiKey) {
return this.post(`${this.centerApiBase}/auth/agent/login`, { apiKey }); return this.post(`${this.centerApiBase}/auth/agent/login`, { apiKey });
@@ -50,4 +68,24 @@ export class FabricClient {
joinChannel(guildEndpoint, guildToken, channelId) { joinChannel(guildEndpoint, guildToken, channelId) {
return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken); return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken);
} }
// ---- channel canvas (one pinned doc per channel) ----
canvasUrl(endpoint, channelId) {
return `${endpoint}/api/channels/${channelId}/canvas`;
}
// null when the channel has no canvas
getCanvas(endpoint, token, channelId) {
return this.req('GET', this.canvasUrl(endpoint, channelId), token);
}
// share / replace (caller becomes the sharer)
shareCanvas(endpoint, token, channelId, body) {
return this.req('PUT', this.canvasUrl(endpoint, channelId), token, body);
}
// update in place (original sharer only — else the guild returns 403)
updateCanvas(endpoint, token, channelId, body) {
return this.req('PATCH', this.canvasUrl(endpoint, channelId), token, body);
}
// remove ("close") the canvas (original sharer only)
removeCanvas(endpoint, token, channelId) {
return this.req('DELETE', this.canvasUrl(endpoint, channelId), token);
}
} }

View File

@@ -89,4 +89,78 @@ export function registerFabricTools(api, client, identity) {
return { ok: true, closed: true }; return { ok: true, closed: true };
}, },
})); }));
// fabric-canvas: share / update / read / close the channel's single
// pinned canvas document (one tool, four actions). update/close are
// sharer-only server-side (the guild returns 403 otherwise).
api.registerTool((ctx) => ({
name: 'fabric-canvas',
description: "Manage a channel's pinned canvas document. action: " +
"read (current canvas or null) | share (create/replace; you become " +
'the sharer) | update (edit in place; sharer only) | close (remove; ' +
'sharer only).',
parameters: {
type: 'object',
additionalProperties: false,
required: ['action', 'guildNodeId', 'channelId'],
properties: {
action: { type: 'string', enum: ['read', 'share', 'update', 'close'] },
guildNodeId: { type: 'string' },
channelId: { type: 'string' },
title: { type: 'string', description: 'share: required; update: optional' },
format: {
type: 'string',
enum: ['md', 'html', 'text'],
description: 'share: required; update: optional',
},
source: {
type: 'string',
description: 'document body. share: required; update: optional',
},
},
},
execute: async (p) => {
const agentId = ctx.agentId;
if (!agentId)
return { ok: false, error: 'no agent context' };
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
const ep = guild.endpoint;
switch (p.action) {
case 'read': {
const canvas = await client.getCanvas(ep, token, p.channelId);
return { ok: true, canvas };
}
case 'share': {
if (!p.title || !p.format || p.source === undefined) {
return { ok: false, error: 'share requires title, format, and source' };
}
const canvas = await client.shareCanvas(ep, token, p.channelId, {
title: p.title,
format: p.format,
source: p.source,
});
return { ok: true, canvas };
}
case 'update': {
const body = {};
if (p.title !== undefined)
body.title = p.title;
if (p.format !== undefined)
body.format = p.format;
if (p.source !== undefined)
body.source = p.source;
if (Object.keys(body).length === 0) {
return { ok: false, error: 'update needs at least one of title/format/source' };
}
const canvas = await client.updateCanvas(ep, token, p.channelId, body);
return { ok: true, canvas };
}
case 'close': {
await client.removeCanvas(ep, token, p.channelId);
return { ok: true, removed: true };
}
default:
return { ok: false, error: `unknown action ${String(p.action)}` };
}
},
}));
} }

View File

@@ -29,6 +29,30 @@ export class FabricClient {
return (await res.json()) as T; return (await res.json()) as T;
} }
// Generic JSON request (GET/PUT/PATCH/DELETE). Empty 2xx body -> null
// (Fabric returns an empty body when a channel has no canvas).
private async req<T>(
method: string,
url: string,
auth?: string,
body?: unknown,
): Promise<T> {
const res = await fetch(url, {
method,
headers: {
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
...(auth ? { authorization: `Bearer ${auth}` } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${method} ${url} -> ${res.status} ${text}`);
}
const text = await res.text();
return (text ? JSON.parse(text) : null) as T;
}
// Exchange an agent API key for a Fabric user session (+ guild tokens). // Exchange an agent API key for a Fabric user session (+ guild tokens).
agentLogin(apiKey: string): Promise<FabricSession> { agentLogin(apiKey: string): Promise<FabricSession> {
return this.post<FabricSession>(`${this.centerApiBase}/auth/agent/login`, { apiKey }); return this.post<FabricSession>(`${this.centerApiBase}/auth/agent/login`, { apiKey });
@@ -86,4 +110,57 @@ export class FabricClient {
joinChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise<unknown> { joinChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise<unknown> {
return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken); return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken);
} }
// ---- channel canvas (one pinned doc per channel) ----
private canvasUrl(endpoint: string, channelId: string): string {
return `${endpoint}/api/channels/${channelId}/canvas`;
} }
// null when the channel has no canvas
getCanvas(
endpoint: string,
token: string,
channelId: string,
): Promise<FabricCanvas | null> {
return this.req('GET', this.canvasUrl(endpoint, channelId), token);
}
// share / replace (caller becomes the sharer)
shareCanvas(
endpoint: string,
token: string,
channelId: string,
body: CanvasInput,
): Promise<FabricCanvas> {
return this.req('PUT', this.canvasUrl(endpoint, channelId), token, body);
}
// update in place (original sharer only — else the guild returns 403)
updateCanvas(
endpoint: string,
token: string,
channelId: string,
body: Partial<CanvasInput>,
): Promise<FabricCanvas> {
return this.req('PATCH', this.canvasUrl(endpoint, channelId), token, body);
}
// remove ("close") the canvas (original sharer only)
removeCanvas(endpoint: string, token: string, channelId: string): Promise<unknown> {
return this.req('DELETE', this.canvasUrl(endpoint, channelId), token);
}
}
export type CanvasFormat = 'md' | 'html' | 'text';
export type CanvasInput = { title: string; format: CanvasFormat; source: string };
export type FabricCanvas = {
channelId: string;
sharerUserId: string;
title: string;
format: CanvasFormat;
source: string;
version: number;
createdAt: string;
updatedAt: string;
};

View File

@@ -120,4 +120,83 @@ export function registerFabricTools(
return { ok: true, closed: true }; return { ok: true, closed: true };
}, },
})); }));
// fabric-canvas: share / update / read / close the channel's single
// pinned canvas document (one tool, four actions). update/close are
// sharer-only server-side (the guild returns 403 otherwise).
api.registerTool((ctx: Ctx) => ({
name: 'fabric-canvas',
description:
"Manage a channel's pinned canvas document. action: " +
"read (current canvas or null) | share (create/replace; you become " +
'the sharer) | update (edit in place; sharer only) | close (remove; ' +
'sharer only).',
parameters: {
type: 'object',
additionalProperties: false,
required: ['action', 'guildNodeId', 'channelId'],
properties: {
action: { type: 'string', enum: ['read', 'share', 'update', 'close'] },
guildNodeId: { type: 'string' },
channelId: { type: 'string' },
title: { type: 'string', description: 'share: required; update: optional' },
format: {
type: 'string',
enum: ['md', 'html', 'text'],
description: 'share: required; update: optional',
},
source: {
type: 'string',
description: 'document body. share: required; update: optional',
},
},
},
execute: async (p: {
action: 'read' | 'share' | 'update' | 'close';
guildNodeId: string;
channelId: string;
title?: string;
format?: 'md' | 'html' | 'text';
source?: string;
}) => {
const agentId = ctx.agentId;
if (!agentId) return { ok: false, error: 'no agent context' };
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
const ep = guild.endpoint;
switch (p.action) {
case 'read': {
const canvas = await client.getCanvas(ep, token, p.channelId);
return { ok: true, canvas };
}
case 'share': {
if (!p.title || !p.format || p.source === undefined) {
return { ok: false, error: 'share requires title, format, and source' };
}
const canvas = await client.shareCanvas(ep, token, p.channelId, {
title: p.title,
format: p.format,
source: p.source,
});
return { ok: true, canvas };
}
case 'update': {
const body: Partial<{ title: string; format: 'md' | 'html' | 'text'; source: string }> = {};
if (p.title !== undefined) body.title = p.title;
if (p.format !== undefined) body.format = p.format;
if (p.source !== undefined) body.source = p.source;
if (Object.keys(body).length === 0) {
return { ok: false, error: 'update needs at least one of title/format/source' };
}
const canvas = await client.updateCanvas(ep, token, p.channelId, body);
return { ok: true, canvas };
}
case 'close': {
await client.removeCanvas(ep, token, p.channelId);
return { ok: true, removed: true };
}
default:
return { ok: false, error: `unknown action ${String(p.action)}` };
}
},
}));
} }