@passioncode-ai/fabric-agent-adapter 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +6 -2
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +22 -13
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +174 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +2 -2
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +50 -2
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1104 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +337 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1274 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +397 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +43 -2
  16. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  17. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +20 -2
  18. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +37 -7
  19. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
@@ -0,0 +1,337 @@
1
+ // The Fabric hub's door, as a service agent reaches it (fabric-hub/0.1; Fabric ADR-0115 §1, ADR-0149 §4; FAA-12b).
2
+ // The twin of fabric_hub.py, same rules and the same answers; Node built-ins only.
3
+ //
4
+ // 1. Find the hub, every time: locateHub() re-reads hub.json beside Fabric's registry (macOS
5
+ // ~/Library/Application Support/ai.passioncode.fabric, Windows %LOCALAPPDATA%\passioncode-fabric, Linux
6
+ // $XDG_DATA_HOME/passioncode-fabric) the way Fabric's own locateHub does: a small regular file, never a link or a
7
+ // FIFO; protocol fabric-hub/0.1; origin exactly http://127.0.0.1:<port>; MCP at /mcp; a door token file inside the
8
+ // registry root; a pid that is alive. The port does not prove who holds it, so a binding credential is sent only
9
+ // after this check passes, done again before each send.
10
+ // 2. Ask once, keep the credential in a file: requestBoardAccess() asks for the board.respond grant through the door;
11
+ // pollAccess() writes the credential, shown once, to a 0600 file and answers without it. Tokens live in files and
12
+ // headers only — never argv, a URL, a log line or an error text.
13
+ // 3. Join the board: joinBoard() calls fabric.board.join {projectId} and answers a BoardGrant (a one-session bearer
14
+ // that fabric-board.mjs's BoardSession spends with one MCP initialize). Without a grant: HubError not_authorized
15
+ // whose data.ask holds the fabric.access.request arguments.
16
+ //
17
+ // Fabric down: every call throws HubError with retryable true; Backoff gives the bounded, jittered wait (2 s doubling to
18
+ // 5 minutes), so nothing spins. fabric-hub/0.1 publishes no hub key; a later hub.json naming hubKey {kid, x} is
19
+ // checked with fabric-estate.mjs's hubKeyCertified when the caller passes an estate record, refused with DEC-0060's
20
+ // own detail.
21
+
22
+ import fs from 'node:fs';
23
+ import http from 'node:http';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+ import { atomicWrite, ensurePrivateDir, readToken } from './fabric-service.mjs';
27
+ import { hubKeyCertified } from './fabric-estate.mjs';
28
+
29
+ export const HUB_PROTOCOL = 'fabric-hub/0.1';
30
+ export const MCP_PROTOCOL_VERSION = '2025-06-18';
31
+ export const READ_CAP = 64 * 1024;
32
+ const LOOPBACK_ORIGIN = /^http:\/\/127\.0\.0\.1:(\d{4,5})$/;
33
+ const TOKEN = /^[A-Za-z0-9_-]{43}$/;
34
+ const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
35
+ const CAPABILITY = /^[a-z][a-z0-9._-]{1,127}$/;
36
+
37
+ /** A typed refusal or fault: code, one-sentence message, retryable, data (never a secret). */
38
+ export class HubError extends Error {
39
+ constructor(code, message, retryable = false, data = {}) {
40
+ super(message);
41
+ this.name = 'HubError';
42
+ this.code = code;
43
+ this.retryable = retryable;
44
+ this.data = data ?? {};
45
+ }
46
+ }
47
+
48
+ /** Fabric's registry root, where hub.json lives (Fabric agentRegistry.ts#registryDirs). */
49
+ export function fabricRoot(platform = process.platform, env = process.env) {
50
+ const home = env.HOME || env.USERPROFILE || os.homedir();
51
+ if (platform === 'darwin') return path.join(home, 'Library/Application Support/ai.passioncode.fabric');
52
+ if (platform === 'win32') return path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'passioncode-fabric');
53
+ return path.join(env.XDG_DATA_HOME || path.join(home, '.local/share'), 'passioncode-fabric');
54
+ }
55
+
56
+ /** Whether a process with this id runs as this user (Fabric hub.ts#pidAlive): EPERM is another user's process. */
57
+ export function pidAlive(pid) {
58
+ if (!Number.isInteger(pid) || pid <= 0) return false;
59
+ try {
60
+ process.kill(pid, 0);
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ function readSmall(file) {
68
+ const info = fs.lstatSync(file);
69
+ if (!info.isFile() || info.size > READ_CAP) return null;
70
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
71
+ try {
72
+ if (!fs.fstatSync(fd).isFile()) return null;
73
+ const buf = Buffer.alloc(READ_CAP + 1);
74
+ let got = 0;
75
+ for (;;) {
76
+ const n = fs.readSync(fd, buf, got, buf.length - got, null);
77
+ if (n === 0) break;
78
+ got += n;
79
+ if (got > READ_CAP) return null;
80
+ }
81
+ return buf.subarray(0, got).toString('utf8');
82
+ } finally {
83
+ fs.closeSync(fd);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * hub.json read the way an agent must, every call. Answers {origin, mcpUrl, doorTokenFile, pid, startedAt, features} or
89
+ * throws HubError (not-running, retryable; not-a-hub). Reads no token. `estate` is {record, applied}: with it, a hubKey
90
+ * the document names is checked by hubKeyCertified (DEC-0060); fabric-hub/0.1 names none.
91
+ */
92
+ export function locateHub(root = fabricRoot(), { alive = pidAlive, estate, now } = {}) {
93
+ const hubFile = path.join(root, 'hub.json');
94
+ const notHub = (reason) => new HubError('not-a-hub', `Fabric's hub.json ${reason}.`);
95
+ let text;
96
+ try {
97
+ text = readSmall(hubFile);
98
+ } catch (e) {
99
+ if (e.code === 'ENOENT') throw new HubError('not-running', 'Fabric is not running: there is no hub.json.', true);
100
+ throw new HubError('not-a-hub', `Fabric's hub.json could not be read (${e.code ?? 'error'}).`, true);
101
+ }
102
+ if (text === null) throw notHub('is not a small regular file');
103
+ let doc;
104
+ try {
105
+ doc = JSON.parse(text);
106
+ } catch {
107
+ throw notHub('is not JSON');
108
+ }
109
+ if (!doc || typeof doc !== 'object' || doc.protocol !== HUB_PROTOCOL) throw notHub(`does not speak ${HUB_PROTOCOL}`);
110
+ const origin = typeof doc.origin === 'string' ? doc.origin : '';
111
+ const m = LOOPBACK_ORIGIN.exec(origin);
112
+ if (!m || Number(m[1]) < 1024 || Number(m[1]) > 65535) throw notHub('names an origin that is not the loopback literal');
113
+ if (doc.mcp !== '/mcp') throw notHub('names another MCP path');
114
+ if (!Number.isInteger(doc.pid) || doc.pid <= 0) throw notHub('names no process');
115
+ const tokenFile = typeof doc.doorTokenFile === 'string' ? doc.doorTokenFile : '';
116
+ let sameRoot = false;
117
+ try {
118
+ sameRoot = path.isAbsolute(tokenFile) && fs.realpathSync(path.dirname(tokenFile)) === fs.realpathSync(root);
119
+ } catch {
120
+ sameRoot = false;
121
+ }
122
+ if (!sameRoot) throw notHub('names a door token outside the registry root');
123
+ if (!alive(doc.pid)) throw new HubError('not-running', `Fabric is not running: hub.json names process ${doc.pid}, which is not running.`, true);
124
+ const features = Array.isArray(doc.features) ? doc.features.filter((f) => typeof f === 'string') : [];
125
+ if (estate && doc.hubKey && typeof doc.hubKey === 'object') {
126
+ const verdict = hubKeyCertified(estate.record, doc.hubKey.kid, doc.hubKey.x, now ?? Date.now() / 1000, estate.applied ?? undefined);
127
+ if (!verdict.certified) throw new HubError('hub-key-refused', `The estate record does not certify the hub's key (${verdict.detail}).`, false, { fault: verdict.fault, detail: verdict.detail });
128
+ }
129
+ return { origin, mcpUrl: `${origin}/mcp`, doorTokenFile: tokenFile, pid: doc.pid, startedAt: doc.startedAt, features };
130
+ }
131
+
132
+ /** The door token hub.json names: a 0600 file of this user, never a link, 43 base64url characters. */
133
+ export function readDoorToken(location) {
134
+ let token;
135
+ try {
136
+ token = readToken(location.doorTokenFile);
137
+ } catch (e) {
138
+ if (e.code === 'ENOENT') throw new HubError('not-running', 'Fabric is starting or stopping: its door token is not there.', true);
139
+ throw new HubError('not-a-hub', `The door token file is not one Fabric writes (${e.message}).`);
140
+ }
141
+ if (!TOKEN.test(token)) throw new HubError('not-a-hub', 'The door token is not one Fabric writes.');
142
+ return token;
143
+ }
144
+
145
+ /** A binding credential from its 0600 file: 43 base64url characters. */
146
+ export function readCredential(file) {
147
+ let token;
148
+ try {
149
+ token = readToken(file);
150
+ } catch (e) {
151
+ if (e.code === 'ENOENT') throw new HubError('credential', `No binding credential at ${file}: ask for the grant first (requestBoardAccess).`);
152
+ throw new HubError('credential', e.message);
153
+ }
154
+ if (!TOKEN.test(token)) throw new HubError('credential', `The file ${file} does not hold a Fabric binding credential.`);
155
+ return token;
156
+ }
157
+
158
+ /**
159
+ * One MCP request to <origin>/mcp with a bearer in the header; only http://127.0.0.1:<port>, no proxy. Answers
160
+ * {status, headers, body} (body parsed JSON or null; an SSE answer read to its first data: line). A refused connection
161
+ * is HubError hub-unavailable (retryable).
162
+ */
163
+ export function postMcp(origin, body, bearer, { session, timeout = 15000, method = 'POST', protocolVersion } = {}) {
164
+ const m = LOOPBACK_ORIGIN.exec(origin);
165
+ if (!m) return Promise.reject(new HubError('not-a-hub', 'Only the hub\'s loopback origin is used.'));
166
+ const payload = method === 'DELETE' ? null : Buffer.from(JSON.stringify(body));
167
+ const headers = { authorization: `Bearer ${bearer}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream', host: `127.0.0.1:${m[1]}` };
168
+ if (session) headers['mcp-session-id'] = session;
169
+ if (protocolVersion) headers['mcp-protocol-version'] = protocolVersion;
170
+ if (payload) headers['content-length'] = String(payload.length);
171
+ return new Promise((resolve, reject) => {
172
+ const req = http.request({ host: '127.0.0.1', port: Number(m[1]), path: '/mcp', method, headers, timeout, agent: false }, (res) => {
173
+ const chunks = [];
174
+ let size = 0;
175
+ res.on('data', (c) => {
176
+ size += c.length;
177
+ if (size <= 4 * 1024 * 1024) chunks.push(c);
178
+ });
179
+ res.on('end', () => {
180
+ let text = Buffer.concat(chunks).toString('utf8');
181
+ if (String(res.headers['content-type'] ?? '').includes('text/event-stream')) {
182
+ text = (text.split(/\r?\n/).find((l) => l.startsWith('data:')) ?? '').slice(5).trim();
183
+ }
184
+ let parsed = null;
185
+ try {
186
+ parsed = text.trim() ? JSON.parse(text) : null;
187
+ } catch {
188
+ parsed = null;
189
+ }
190
+ resolve({ status: res.statusCode, headers: res.headers, body: parsed });
191
+ });
192
+ res.on('error', () => reject(new HubError('hub-unavailable', 'Fabric\'s hub did not answer (the answer broke off).', true)));
193
+ });
194
+ req.on('timeout', () => req.destroy(new Error('timeout')));
195
+ req.on('error', (e) => reject(new HubError('hub-unavailable', `Fabric's hub did not answer (${e.code ?? e.message}).`, true)));
196
+ if (payload) req.write(payload);
197
+ req.end();
198
+ });
199
+ }
200
+
201
+ /** A tools/call answer as an object: structuredContent, else the first text content parsed. */
202
+ export function toolAnswer({ status, body }, tool) {
203
+ if (status === 401) throw new HubError('credential', `The hub refused the credential for ${tool}.`);
204
+ if (status === 429 || status >= 500) throw new HubError('hub-unavailable', `Fabric's hub could not take ${tool} just now (HTTP ${status}).`, true);
205
+ if (status !== 200 || !body || typeof body !== 'object') throw new HubError('protocol', `Fabric's hub answered ${tool} with HTTP ${status} and no MCP answer.`, true);
206
+ if (body.error) throw new HubError('protocol', `Fabric refused ${tool}: ${String(body.error?.message ?? 'an MCP error').slice(0, 200)}`);
207
+ const result = body.result;
208
+ if (!result || typeof result !== 'object') throw new HubError('protocol', `Fabric's answer to ${tool} has no result.`);
209
+ let value = result.structuredContent;
210
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
211
+ const text = Array.isArray(result.content) && result.content[0] && typeof result.content[0].text === 'string' ? result.content[0].text : null;
212
+ try {
213
+ value = text === null ? null : JSON.parse(text);
214
+ } catch {
215
+ value = null;
216
+ }
217
+ }
218
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new HubError('protocol', `Fabric's answer to ${tool} is not a JSON object.`);
219
+ return value;
220
+ }
221
+
222
+ /** What fabric.board.join handed out. The bearer is a private field, kept off JSON and inspection. */
223
+ export class BoardGrant {
224
+ #bearer;
225
+ constructor(bearer, sessionId, projectId, capabilities, origin, pid) {
226
+ this.#bearer = bearer;
227
+ this.sessionId = sessionId;
228
+ this.projectId = projectId;
229
+ this.capabilities = [...capabilities];
230
+ this.origin = origin;
231
+ this.pid = pid;
232
+ }
233
+ bearer() {
234
+ return this.#bearer;
235
+ }
236
+ }
237
+
238
+ /** The grant's resource, board:<project>/<capability> (ADR-0149 §2). */
239
+ export const boardResource = (projectId, capability) => `board:${projectId}/${capability}`;
240
+
241
+ /** A Project id as Fabric's board compares it: a uuid lower-cased; other ids are opaque, compared as written (DEC-0077). */
242
+ export function normalProject(projectId) {
243
+ if (typeof projectId !== 'string' || !UUID.test(projectId)) throw new HubError('protocol', 'fabric.board.join takes a Project id that is a uuid.');
244
+ return projectId.toLowerCase();
245
+ }
246
+
247
+ /** The door client. Every call re-reads hub.json first. `alive` and `post` are seams for tests. */
248
+ export class HubClient {
249
+ constructor(root = fabricRoot(), { alive = pidAlive, timeout = 15000, estate, post = postMcp } = {}) {
250
+ Object.assign(this, { root, alive, timeout, estate, post });
251
+ this.rpc = 0;
252
+ }
253
+
254
+ locate() {
255
+ return locateHub(this.root, { alive: this.alive, estate: this.estate });
256
+ }
257
+
258
+ async #call(bearerOf, tool, args) {
259
+ const location = this.locate(); // the pid check, right before the credential leaves this process
260
+ const bearer = bearerOf(location);
261
+ this.rpc += 1;
262
+ const answer = await this.post(location.origin, { jsonrpc: '2.0', id: this.rpc, method: 'tools/call', params: { name: tool, arguments: args } }, bearer, { timeout: this.timeout });
263
+ const value = toolAnswer(answer, tool);
264
+ if (value.error && typeof value.error === 'object' && typeof value.error.code === 'string') {
265
+ const data = value.error.data && typeof value.error.data === 'object' ? value.error.data : {};
266
+ throw new HubError(value.error.code, String(value.error.message ?? 'refused').slice(0, 300), value.error.code === 'hub-unavailable', data);
267
+ }
268
+ return value;
269
+ }
270
+
271
+ callDoor(tool, args) {
272
+ return this.#call(readDoorToken, tool, args);
273
+ }
274
+
275
+ callBinding(tool, args, credentialFile) {
276
+ return this.#call(() => readCredential(credentialFile), tool, args);
277
+ }
278
+
279
+ /** Ask the operator, once, to let agentId answer capabilities for projectId (fabric.access.request, board.respond). */
280
+ async requestBoardAccess(agentId, projectId, capabilities, reason) {
281
+ const project = normalProject(projectId);
282
+ const caps = [...(capabilities ?? [])];
283
+ if (!caps.length || caps.some((c) => typeof c !== 'string' || !CAPABILITY.test(c))) throw new HubError('protocol', 'Name at least one capability, each [a-z][a-z0-9._-]{1,127}.');
284
+ return this.callDoor('fabric.access.request', { agentId, callee: 'fabric', capabilities: ['board.respond'], resources: caps.map((c) => boardResource(project, c)), reason });
285
+ }
286
+
287
+ /** Read the request through the door; on Allow the credential is written to credentialFile (0600) and left out. */
288
+ async pollAccess(requestId, pollSecret, credentialFile) {
289
+ const answer = await this.callDoor('fabric.access.status', { requestId, pollSecret });
290
+ const { credential, ...rest } = answer;
291
+ if (typeof credential === 'string') {
292
+ if (!TOKEN.test(credential)) throw new HubError('protocol', 'Fabric handed out a credential this kit does not recognise; nothing was saved.');
293
+ ensurePrivateDir(path.dirname(credentialFile));
294
+ atomicWrite(credentialFile, credential, 0o600);
295
+ rest.credentialSaved = true;
296
+ }
297
+ return rest;
298
+ }
299
+
300
+ /** fabric.board.join {projectId}: a board session for one Project. */
301
+ async joinBoard(projectId, credentialFile) {
302
+ const project = normalProject(projectId);
303
+ let answer;
304
+ try {
305
+ answer = await this.callBinding('fabric.board.join', { projectId: project }, credentialFile);
306
+ } catch (e) {
307
+ if (e instanceof HubError && (e.code === 'not_authorized' || e.code === 'not-authorized')) {
308
+ throw new HubError('not_authorized', 'The operator has not let this agent answer requests for that Project: ask with fabric.access.request (callee fabric, capability board.respond, resource board:<project>/<capability>).',
309
+ false, { ask: { callee: 'fabric', capabilities: ['board.respond'], resources: [boardResource(project, '<capability>')] } });
310
+ }
311
+ throw e;
312
+ }
313
+ const s = answer.session;
314
+ if (!s || typeof s !== 'object' || typeof s.bearer !== 'string' || typeof s.sessionId !== 'string') throw new HubError('protocol', 'fabric.board.join answered no board session.');
315
+ if (s.projectId !== project) throw new HubError('protocol', 'fabric.board.join answered a session for another Project.');
316
+ const caps = Array.isArray(s.capabilities) ? s.capabilities.filter((c) => typeof c === 'string') : [];
317
+ const location = this.locate();
318
+ return new BoardGrant(s.bearer, s.sessionId, project, caps, location.origin, location.pid);
319
+ }
320
+ }
321
+
322
+ /** Bounded exponential backoff with jitter: first seconds doubling to cap, ±jitter, never under floor. */
323
+ export class Backoff {
324
+ constructor({ first = 2, cap = 300, jitter = 0.2, floor = 1, rand = Math.random } = {}) {
325
+ Object.assign(this, { first, cap, jitter, floor, rand });
326
+ this.attempt = 0;
327
+ }
328
+ next() {
329
+ const base = Math.min(this.cap, this.first * 2 ** Math.min(this.attempt, 32));
330
+ this.attempt += 1;
331
+ const wait = base * (1 + this.jitter * (2 * this.rand() - 1));
332
+ return Math.max(this.floor, Math.min(this.cap, wait));
333
+ }
334
+ reset() {
335
+ this.attempt = 0;
336
+ }
337
+ }