1 Commits

Author SHA1 Message Date
26c12533fb refactor(plugin): fabric-register is a script, not a tool
Binding an agent's Fabric API key was an OpenClaw tool; make it a
self-contained Node script installed to ~/.openclaw/bin/fabric-register
instead.

- bin/fabric-register.mjs: no plugin deps; AGENT_ID env wins, else
  --agent-id required; --api-key validated via POST /auth/agent/login;
  on success upserts ~/.openclaw/fabric-identity.json (format matches
  IdentityRegistry). Flags/env for center, identity-file, openclaw-path.
- install.mjs: copy the script to ~/.openclaw/bin (chmod 0755) on
  install, remove on uninstall; Next-steps updated.
- tools.ts: drop the fabric-register tool; ctxGuild error now points to
  the script / static accounts config.
- README updated.

Verified: missing-id -> exit 2; --agent-id and AGENT_ID both bind and
write a valid identity file; bad key -> 401, no write.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:12:48 +01:00
5 changed files with 232 additions and 59 deletions

View File

@@ -38,8 +38,31 @@ host and OpenClaw's SSRF guard would block re-fetching it.
Each agent has a Fabric Center **API key** (mint via Center CLI: Each agent has a Fabric Center **API key** (mint via Center CLI:
`node dist/cli.js user apikey --email <agent-email>`). The key is exchanged `node dist/cli.js user apikey --email <agent-email>`). The key is exchanged
for a user session (`POST /auth/agent/login`). Bind a key to an agent with for a user session (`POST /auth/agent/login`).
the `fabric-register` tool, or via `channels.fabric.accounts`.
### Binding a key to an agent (one-time)
Two ways, both write the same identity registry the transport reads:
1. **Static config** — set `channels.fabric.accounts.<agentId> =
{ fabricApiKey, enabled }`. The agent never runs anything.
2. **`fabric-register` script** — installed to `~/.openclaw/bin/fabric-register`
by the installer (it is **not** an OpenClaw tool):
```bash
# agent id from $AGENT_ID (set in the agent runtime):
~/.openclaw/bin/fabric-register --api-key fak_…
# or pass it explicitly:
~/.openclaw/bin/fabric-register --agent-id <agent> --api-key fak_…
```
It validates the key against Center, then writes
`~/.openclaw/fabric-identity.json`. One-time and persistent — *not* per
login; the plugin's transport logs in and stays connected on its own.
`AGENT_ID` env wins; otherwise `--agent-id` is required. Other flags:
`--center`, `--identity-file`, `--openclaw-path` (env equivalents:
`FABRIC_API_KEY`, `FABRIC_CENTER_API_BASE`, `FABRIC_IDENTITY_FILE`,
`OPENCLAW_PATH`). Restart the gateway afterwards.
## Config ## Config
@@ -61,7 +84,8 @@ Then `openclaw gateway restart`.
## Tools ## Tools
- `fabric-register` — bind this agent's Fabric API key (Key binding is **not** a tool — see *Binding a key to an agent* above.)
- `create-chat-channel` (general) / `create-work-channel` (work) / - `create-chat-channel` (general) / `create-work-channel` (work) /
`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

164
bin/fabric-register.mjs Normal file
View File

@@ -0,0 +1,164 @@
#!/usr/bin/env node
/**
* fabric-register — bind an OpenClaw agent to a Fabric Center API key.
*
* One-time, self-contained (no plugin deps). Installed to
* ~/.openclaw/bin/fabric-register by the plugin installer.
*
* AGENT_ID is read from the environment if set; otherwise --agent-id is
* required. The API key is validated against Center (POST
* /auth/agent/login) and, on success, written to the plugin's identity
* file so the Fabric channel plugin can connect that agent.
*
* Usage:
* fabric-register --api-key fak_xxx # uses $AGENT_ID
* fabric-register --agent-id echo --api-key fak_xxx
*
* Flags / env:
* --agent-id <id> (or env AGENT_ID; one of them required)
* --api-key <fak_…> (or env FABRIC_API_KEY; required)
* --center <url> (or env FABRIC_CENTER_API_BASE; else openclaw.json;
* else http://localhost:7001/api)
* --identity-file <path> (or env FABRIC_IDENTITY_FILE; else
* ~/.openclaw/fabric-identity.json)
* --openclaw-path <dir> (or env OPENCLAW_PATH; else ~/.openclaw)
* -h | --help
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { homedir } from 'node:os';
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '-h' || a === '--help') out.help = true;
else if (a.startsWith('--')) {
const key = a.slice(2);
const v = argv[i + 1];
if (v === undefined || v.startsWith('--')) out[key] = true;
else {
out[key] = v;
i++;
}
}
}
return out;
}
const HELP = `fabric-register — bind an OpenClaw agent to a Fabric Center API key
fabric-register --api-key fak_xxx # agent id from $AGENT_ID
fabric-register --agent-id <id> --api-key fak_xxx
--agent-id <id> required unless $AGENT_ID is set
--api-key <fak_…> required (or env FABRIC_API_KEY)
--center <url> Center API base (or env FABRIC_CENTER_API_BASE;
else openclaw.json channels.fabric.centerApiBase;
else http://localhost:7001/api)
--identity-file <path> default ~/.openclaw/fabric-identity.json
--openclaw-path <dir> default ~/.openclaw
-h, --help
`;
function fail(msg) {
console.error(`fabric-register: ${msg}`);
process.exit(2);
}
async function main() {
const a = parseArgs(process.argv.slice(2));
if (a.help) {
process.stdout.write(HELP);
return;
}
// agent id: env AGENT_ID wins; else --agent-id is required.
const agentId =
(process.env.AGENT_ID && process.env.AGENT_ID.trim()) ||
(typeof a['agent-id'] === 'string' && a['agent-id'].trim());
if (!agentId) {
fail('no agent id: set the AGENT_ID environment variable or pass --agent-id <id>');
}
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_…> (or env FABRIC_API_KEY)');
const openclawPath = resolve(
(typeof a['openclaw-path'] === 'string' && a['openclaw-path']) ||
process.env.OPENCLAW_PATH ||
join(homedir(), '.openclaw'),
);
// center api base: flag > env > openclaw.json > default
let center =
(typeof a.center === 'string' && a.center) ||
process.env.FABRIC_CENTER_API_BASE ||
'';
if (!center) {
try {
const cfg = JSON.parse(readFileSync(join(openclawPath, 'openclaw.json'), 'utf8'));
center = cfg?.channels?.fabric?.centerApiBase || '';
} catch {
/* fall through to default */
}
}
if (!center) center = 'http://localhost:7001/api';
center = center.replace(/\/+$/, '');
const identityFile = resolve(
(typeof a['identity-file'] === 'string' && a['identity-file']) ||
process.env.FABRIC_IDENTITY_FILE ||
join(openclawPath, 'fabric-identity.json'),
);
// 1) validate the key against Center (also resolves the Fabric identity)
let session;
try {
const res = await fetch(`${center}/auth/agent/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ apiKey }),
});
if (!res.ok) {
const t = await res.text().catch(() => '');
fail(`Center rejected the key: POST ${center}/auth/agent/login -> ${res.status} ${t}`);
}
session = await res.json();
} catch (e) {
fail(`could not reach Center at ${center}: ${e?.message || e}`);
}
// 2) upsert into the identity file (merge by agentId)
let file = { entries: [] };
if (existsSync(identityFile)) {
try {
const parsed = JSON.parse(readFileSync(identityFile, 'utf8'));
if (parsed && Array.isArray(parsed.entries)) file = parsed;
} catch {
/* corrupt -> overwrite */
}
}
const entry = {
agentId,
fabricApiKey: apiKey,
fabricUserId: session?.user?.id,
displayName: session?.user?.name,
};
const i = file.entries.findIndex((e) => e && e.agentId === agentId);
if (i >= 0) file.entries[i] = { ...file.entries[i], ...entry };
else file.entries.push(entry);
mkdirSync(dirname(identityFile), { recursive: true });
writeFileSync(identityFile, JSON.stringify(file, null, 2));
console.log(
`fabric-register: bound agent "${agentId}" -> Fabric user ` +
`${session?.user?.email || session?.user?.id} (${identityFile}). ` +
`Restart the gateway to connect: openclaw gateway restart`,
);
}
main().catch((e) => fail(e?.message || String(e)));

View File

@@ -9,7 +9,9 @@ export function registerFabricTools(api, client, identity) {
const ctxGuild = async (agentId, guildNodeId) => { const ctxGuild = async (agentId, guildNodeId) => {
const entry = identity.findByAgentId(agentId); const entry = identity.findByAgentId(agentId);
if (!entry) if (!entry)
throw new Error(`agent ${agentId} not registered (call fabric-register)`); throw new Error(`agent ${agentId} not registered — run: AGENT_ID=${agentId} ` +
`~/.openclaw/bin/fabric-register --api-key <fak_…> (or set ` +
`channels.fabric.accounts.${agentId}); then restart the gateway`);
const session = await client.agentLogin(entry.fabricApiKey); const session = await client.agentLogin(entry.fabricApiKey);
const guild = session.guilds.find((g) => g.nodeId === guildNodeId); const guild = session.guilds.find((g) => g.nodeId === guildNodeId);
const token = session.guildAccessTokens.find((t) => t.guildNodeId === guildNodeId)?.token; const token = session.guildAccessTokens.find((t) => t.guildNodeId === guildNodeId)?.token;
@@ -17,32 +19,10 @@ export function registerFabricTools(api, client, identity) {
throw new Error(`agent not a member of guild ${guildNodeId}`); throw new Error(`agent not a member of guild ${guildNodeId}`);
return { session, guild, token }; return { session, guild, token };
}; };
// fabric-register: bind this agent to a Fabric API key. // NOTE: binding an agent's Fabric API key is intentionally NOT a tool.
api.registerTool((ctx) => ({ // It's a one-time step done out-of-band via the installed script
name: 'fabric-register', // ~/.openclaw/bin/fabric-register --api-key <fak_…> (AGENT_ID or --agent-id)
description: "Register this agent's Fabric API key (minted via Center CLI `user apikey`).", // or via static config (channels.fabric.accounts.<agentId>).
parameters: {
type: 'object',
additionalProperties: false,
required: ['fabricApiKey'],
properties: {
fabricApiKey: { type: 'string', description: 'Fabric Center API key (fak_…)' },
},
},
execute: async (params) => {
const agentId = ctx.agentId;
if (!agentId)
return { ok: false, error: 'no agent context' };
const session = await client.agentLogin(params.fabricApiKey);
identity.upsert({
agentId,
fabricApiKey: params.fabricApiKey,
fabricUserId: session.user.id,
displayName: session.user.name,
});
return { ok: true, user: session.user };
},
}));
const makeCreate = (kind) => api.registerTool((ctx) => ({ const makeCreate = (kind) => api.registerTool((ctx) => ({
name: `create-${kind}-channel`, name: `create-${kind}-channel`,
description: `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}).`, description: `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}).`,

View File

@@ -10,7 +10,7 @@
*/ */
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { existsSync, mkdirSync, copyFileSync, readdirSync, rmSync } from 'fs'; import { existsSync, mkdirSync, copyFileSync, readdirSync, rmSync, chmodSync } from 'fs';
import { dirname, join, resolve } from 'path'; import { dirname, join, resolve } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { homedir } from 'os'; import { homedir } from 'os';
@@ -109,12 +109,30 @@ function build() {
ok('compiled -> dist/fabric'); ok('compiled -> dist/fabric');
} }
function binTarget(base) {
return join(base, 'bin', 'fabric-register');
}
function installBinScript(base) {
const src = join(__dirname, 'bin', 'fabric-register.mjs');
const dst = binTarget(base);
mkdirSync(dirname(dst), { recursive: true });
copyFileSync(src, dst);
chmodSync(dst, 0o755);
ok(`fabric-register -> ${dst}`);
}
function clearInstall(base) { function clearInstall(base) {
const dest = join(base, 'plugins', PLUGIN_ID); const dest = join(base, 'plugins', PLUGIN_ID);
if (existsSync(dest)) { if (existsSync(dest)) {
rmSync(dest, { recursive: true, force: true }); rmSync(dest, { recursive: true, force: true });
ok(`removed ${dest}`); ok(`removed ${dest}`);
} }
const bin = binTarget(base);
if (existsSync(bin)) {
rmSync(bin, { force: true });
ok(`removed ${bin}`);
}
} }
function cleanupConfig(base) { function cleanupConfig(base) {
const dest = join(base, 'plugins', PLUGIN_ID); const dest = join(base, 'plugins', PLUGIN_ID);
@@ -149,6 +167,7 @@ function install() {
ok(`plugin files -> ${dest}`); ok(`plugin files -> ${dest}`);
exec('npm install --omit=dev', { cwd: dest, silent: !opt.verbose }); exec('npm install --omit=dev', { cwd: dest, silent: !opt.verbose });
ok('runtime deps installed'); ok('runtime deps installed');
installBinScript(base);
return { base, dest }; return { base, dest };
} }
@@ -200,8 +219,10 @@ function main() {
console.log(''); console.log('');
log('Install complete. Next:', 'blue'); log('Install complete. Next:', 'blue');
log(' 1. Mint an agent key: (in Center) node dist/cli.js user apikey --email <agent-email>', 'cyan'); log(' 1. Mint an agent key: (in Center) node dist/cli.js user apikey --email <agent-email>', 'cyan');
log(' 2. openclaw gateway restart', 'cyan'); log(' 2. Bind it to an agent (one-time), either:', 'cyan');
log(' 3. As an agent, call the fabric-register tool with that key', 'cyan'); log(' AGENT_ID=<agent> ~/.openclaw/bin/fabric-register --api-key <fak_…>', 'cyan');
log(' (or pass --agent-id <agent>; or set channels.fabric.accounts.<agent>)', 'cyan');
log(' 3. openclaw gateway restart', 'cyan');
console.log(''); console.log('');
} catch (e) { } catch (e) {
log(`\nInstall failed: ${e.message}`, 'red'); log(`\nInstall failed: ${e.message}`, 'red');

View File

@@ -25,7 +25,12 @@ export function registerFabricTools(
// 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);
if (!entry) throw new Error(`agent ${agentId} not registered (call fabric-register)`); if (!entry)
throw new Error(
`agent ${agentId} not registered — run: AGENT_ID=${agentId} ` +
`~/.openclaw/bin/fabric-register --api-key <fak_…> (or set ` +
`channels.fabric.accounts.${agentId}); then restart the gateway`,
);
const session = await client.agentLogin(entry.fabricApiKey); const session = await client.agentLogin(entry.fabricApiKey);
const guild = session.guilds.find((g) => g.nodeId === guildNodeId); const guild = session.guilds.find((g) => g.nodeId === guildNodeId);
const token = session.guildAccessTokens.find((t) => t.guildNodeId === guildNodeId)?.token; const token = session.guildAccessTokens.find((t) => t.guildNodeId === guildNodeId)?.token;
@@ -33,31 +38,10 @@ export function registerFabricTools(
return { session, guild, token }; return { session, guild, token };
}; };
// fabric-register: bind this agent to a Fabric API key. // NOTE: binding an agent's Fabric API key is intentionally NOT a tool.
api.registerTool((ctx: Ctx) => ({ // It's a one-time step done out-of-band via the installed script
name: 'fabric-register', // ~/.openclaw/bin/fabric-register --api-key <fak_…> (AGENT_ID or --agent-id)
description: "Register this agent's Fabric API key (minted via Center CLI `user apikey`).", // or via static config (channels.fabric.accounts.<agentId>).
parameters: {
type: 'object',
additionalProperties: false,
required: ['fabricApiKey'],
properties: {
fabricApiKey: { type: 'string', description: 'Fabric Center API key (fak_…)' },
},
},
execute: async (params: { fabricApiKey: string }) => {
const agentId = ctx.agentId;
if (!agentId) return { ok: false, error: 'no agent context' };
const session = await client.agentLogin(params.fabricApiKey);
identity.upsert({
agentId,
fabricApiKey: params.fabricApiKey,
fabricUserId: session.user.id,
displayName: session.user.name,
});
return { ok: true, user: session.user };
},
}));
const makeCreate = (kind: 'chat' | 'work' | 'report' | 'discussion') => const makeCreate = (kind: 'chat' | 'work' | 'report' | 'discussion') =>
api.registerTool((ctx: Ctx) => ({ api.registerTool((ctx: Ctx) => ({