@passioncode-ai/fabric-agent-adapter 0.40.0 → 0.41.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.
- package/CHANGELOG.md +124 -0
- package/README.md +9 -3
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +28 -30
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +47 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +230 -29
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +196 -188
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +48 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +34 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +144 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +7 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +12 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +6 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +17 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +207 -227
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +0 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +52 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +101 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +12 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +13 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +118 -71
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +272 -74
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +36 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +130 -35
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +30 -22
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +37 -6
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +72 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +25 -7
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +199 -43
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +10 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
|
@@ -22,6 +22,13 @@ import { pathToFileURL } from 'node:url';
|
|
|
22
22
|
|
|
23
23
|
const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1';
|
|
24
24
|
const RULES = ['ED-1', 'ED-2', 'ED-3', 'ED-4', 'ED-5'];
|
|
25
|
+
// The most elements ED-4 and ED-5 walk on one page; past it they say how much they did not see.
|
|
26
|
+
const ELEMENT_CAP = 6000;
|
|
27
|
+
|
|
28
|
+
// A one-time login code is a credential: no report, error or evidence line carries one.
|
|
29
|
+
export function scrub(text) {
|
|
30
|
+
return String(text).replace(/([?&#;]code=)[^&#\s"']*/gi, '$1<redacted>');
|
|
31
|
+
}
|
|
25
32
|
|
|
26
33
|
function usage(message) {
|
|
27
34
|
if (message) process.stderr.write(message + '\n');
|
|
@@ -103,7 +110,7 @@ async function serviceTarget(spec, dir) {
|
|
|
103
110
|
}
|
|
104
111
|
|
|
105
112
|
// Runs inside the page. Everything it reports is measured from the rendered layout.
|
|
106
|
-
function measure(floor) {
|
|
113
|
+
function measure([floor, cap]) {
|
|
107
114
|
const de = document.documentElement;
|
|
108
115
|
const vw = de.clientWidth;
|
|
109
116
|
const vh = window.innerHeight;
|
|
@@ -111,6 +118,9 @@ function measure(floor) {
|
|
|
111
118
|
const cs = (el) => getComputedStyle(el);
|
|
112
119
|
const scrollsX = (el) => /(auto|scroll)/.test(cs(el).overflowX);
|
|
113
120
|
const clipsX = (el) => cs(el).overflowX !== 'visible';
|
|
121
|
+
// Vertically a scroll box (auto, scroll) still lets the reader reach what it holds; hidden and clip do not.
|
|
122
|
+
const clipsY = (el) => /(hidden|clip)/.test(cs(el).overflowY);
|
|
123
|
+
const pinned = (el) => { for (let a = el; a && a !== de; a = a.parentElement) if (cs(a).position === 'fixed') return true; return false; };
|
|
114
124
|
const label = (el) => {
|
|
115
125
|
const id = el.id ? '#' + el.id : '';
|
|
116
126
|
const cls = typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).join('.') : '';
|
|
@@ -118,16 +128,22 @@ function measure(floor) {
|
|
|
118
128
|
};
|
|
119
129
|
const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && cs(el).visibility !== 'hidden'; };
|
|
120
130
|
|
|
121
|
-
// ED-3: a link is reachable when it is fully inside the viewport
|
|
122
|
-
//
|
|
131
|
+
// ED-3: a link is reachable when it is fully inside the viewport's width, inside the page's height
|
|
132
|
+
// (the viewport's, for something fixed to it) and not clipped by an ancestor on either axis, or
|
|
133
|
+
// when it sits in a closed <details> whose summary is reachable, or in a menu a visible
|
|
123
134
|
// aria-controls button opens.
|
|
124
135
|
const inViewport = (el) => {
|
|
125
136
|
const r = el.getBoundingClientRect();
|
|
126
137
|
if (r.width <= 0 || r.height <= 0 || r.left < -1 || r.right > vw + 1) return false;
|
|
138
|
+
const [top, bottom] = pinned(el) ? [0, vh] : [-window.scrollY, de.scrollHeight - window.scrollY];
|
|
139
|
+
if (r.top < top - 1 || r.bottom > bottom + 1) return false;
|
|
127
140
|
for (let a = el.parentElement; a && a !== de; a = a.parentElement) {
|
|
128
|
-
|
|
141
|
+
const x = clipsX(a);
|
|
142
|
+
const y = clipsY(a);
|
|
143
|
+
if (!x && !y) continue;
|
|
129
144
|
const ar = a.getBoundingClientRect();
|
|
130
|
-
if (r.left < ar.left - 1 || r.right > ar.right + 1) return false;
|
|
145
|
+
if (x && (r.left < ar.left - 1 || r.right > ar.right + 1)) return false;
|
|
146
|
+
if (y && (r.top < ar.top - 1 || r.bottom > ar.bottom + 1)) return false;
|
|
131
147
|
}
|
|
132
148
|
return true;
|
|
133
149
|
};
|
|
@@ -155,9 +171,11 @@ function measure(floor) {
|
|
|
155
171
|
// unless it is inside a scroll box or clipped by an ancestor that is itself inside the viewport.
|
|
156
172
|
const all = document.body ? document.body.querySelectorAll('*') : [];
|
|
157
173
|
const FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]';
|
|
174
|
+
out.elements = all.length;
|
|
175
|
+
out.measured = Math.min(all.length, cap);
|
|
158
176
|
let n = 0;
|
|
159
177
|
for (const el of all) {
|
|
160
|
-
if (++n >
|
|
178
|
+
if (++n > cap) break;
|
|
161
179
|
if (!visible(el)) continue;
|
|
162
180
|
if (scrollsX(el) && el.scrollWidth > el.clientWidth + 1) {
|
|
163
181
|
const keyboard = el.tabIndex >= 0 || !!el.querySelector(FOCUSABLE);
|
|
@@ -214,21 +232,28 @@ export function verdicts(width, floor, m) {
|
|
|
214
232
|
else add('ED-3', m.nav.unreachable.length ? 'FAIL' : 'PASS',
|
|
215
233
|
m.nav.unreachable.length ? `${m.nav.unreachable.length} of ${m.nav.total} links unreachable: ${m.nav.unreachable.slice(0, 6).join(', ')}` : `${m.nav.total} links reachable`);
|
|
216
234
|
const badBoxes = m.boxes.filter((b) => !b.keyboard || !b.named);
|
|
235
|
+
// A page larger than the walk: what the walk saw can fail, but it cannot pass for the whole page.
|
|
236
|
+
const capped = m.elements > m.measured ? `only the first ${m.measured} of ${m.elements} elements measured` : '';
|
|
217
237
|
const problems = [];
|
|
218
238
|
if (m.past.length) problems.push(`past the viewport, not in a scroll box: ${m.past.slice(0, 4).join(', ')}`);
|
|
219
239
|
if (badBoxes.length) problems.push(`scroll box without keyboard access or a name: ${badBoxes.slice(0, 4).map((b) => b.el).join(', ')}`);
|
|
220
|
-
add('ED-4',
|
|
240
|
+
if (problems.length) add('ED-4', 'FAIL', problems.join('; ') + (capped ? `; ${capped}` : ''));
|
|
241
|
+
else if (capped) add('ED-4', 'NOT_RUN', `${capped}: ${m.boxes.length} overflowing box(es) keyboard-reachable among them, the rest not seen`);
|
|
242
|
+
else add('ED-4', 'PASS', `${m.boxes.length} overflowing box(es), each keyboard-reachable; edge shades not measured`);
|
|
221
243
|
const limit = Math.min(120, m.vh / 4);
|
|
222
244
|
const ed5 = [];
|
|
223
245
|
if (m.chrome > limit) ed5.push(`sticky chrome ${m.chrome} px > ${Math.round(limit)} px`);
|
|
224
246
|
if (m.nested.length) ed5.push(`nested full-height scroller: ${m.nested.slice(0, 3).join(', ')}`);
|
|
225
247
|
if (m.anchor && m.anchor.top < m.chrome - 1) ed5.push(`anchor ${m.anchor.el} lands at ${m.anchor.top} px, under the ${m.chrome} px chrome`);
|
|
226
248
|
add('ED-5', ed5.length ? 'FAIL' : 'PASS', ed5.join('; ') ||
|
|
227
|
-
`sticky chrome ${m.chrome} px ≤ ${Math.round(limit)} px` + (m.anchor ? `; anchor lands at ${m.anchor.top} px` : '; no in-page anchor to jump to') + '; refresh keeps scroll: not measured'
|
|
249
|
+
`sticky chrome ${m.chrome} px ≤ ${Math.round(limit)} px` + (m.anchor ? `; anchor lands at ${m.anchor.top} px` : '; no in-page anchor to jump to') + '; refresh keeps scroll: not measured' +
|
|
250
|
+
(capped ? `; nested scrollers: ${capped}` : ''));
|
|
228
251
|
return rows;
|
|
229
252
|
}
|
|
230
253
|
|
|
231
254
|
function report(rows, opts, target) {
|
|
255
|
+
rows = rows.map((r) => ({ ...r, evidence: scrub(r.evidence) }));
|
|
256
|
+
target = scrub(target);
|
|
232
257
|
const failed = rows.filter((r) => r.verdict === 'FAIL').length;
|
|
233
258
|
if (opts.json) {
|
|
234
259
|
process.stdout.write(JSON.stringify({ target, browser: opts.browser, results: rows, failed }, null, 2) + '\n');
|
|
@@ -241,27 +266,31 @@ function report(rows, opts, target) {
|
|
|
241
266
|
|
|
242
267
|
async function main(argv) {
|
|
243
268
|
const opts = parseArgs(argv);
|
|
269
|
+
// What the report names: the service, never the login URL its one-time code is in.
|
|
270
|
+
const shown = opts.service || opts.url;
|
|
244
271
|
let target = opts.url;
|
|
245
272
|
let floor = opts.floor;
|
|
246
|
-
const notRun = (why) => report(RULES.map((rule) => ({ rule, width: 0, verdict: 'NOT_RUN', evidence: why })), opts,
|
|
273
|
+
const notRun = (why) => report(RULES.map((rule) => ({ rule, width: 0, verdict: 'NOT_RUN', evidence: why })), opts, shown);
|
|
274
|
+
// The browser first: a run that cannot measure must not spend a one-time code.
|
|
275
|
+
const pw = await loadPlaywright(opts.playwright);
|
|
276
|
+
if (!pw) return notRun('Playwright is not installed here: npm i -D playwright && npx playwright install webkit (or pass --playwright DIR)');
|
|
277
|
+
let browser;
|
|
278
|
+
try {
|
|
279
|
+
browser = await pw[opts.browser].launch();
|
|
280
|
+
} catch (err) {
|
|
281
|
+
return notRun(`the ${opts.browser} browser does not start: ${String(err.message).split('\n')[0]} (npx playwright install ${opts.browser})`);
|
|
282
|
+
}
|
|
247
283
|
if (opts.service) {
|
|
248
284
|
try {
|
|
249
285
|
const t = await serviceTarget(opts.service, opts.servicesDir);
|
|
250
286
|
target = t.url;
|
|
251
287
|
if (floor === undefined && Number.isInteger(t.declared?.minWidth)) floor = t.declared.minWidth;
|
|
252
288
|
} catch (err) {
|
|
289
|
+
await browser.close();
|
|
253
290
|
return notRun(`could not open the service's dashboard: ${err.message}`);
|
|
254
291
|
}
|
|
255
292
|
}
|
|
256
293
|
floor = floor ?? 360;
|
|
257
|
-
const pw = await loadPlaywright(opts.playwright);
|
|
258
|
-
if (!pw) return notRun('Playwright is not installed here: npm i -D playwright && npx playwright install webkit (or pass --playwright DIR)');
|
|
259
|
-
let browser;
|
|
260
|
-
try {
|
|
261
|
-
browser = await pw[opts.browser].launch();
|
|
262
|
-
} catch (err) {
|
|
263
|
-
return notRun(`the ${opts.browser} browser does not start: ${String(err.message).split('\n')[0]} (npx playwright install ${opts.browser})`);
|
|
264
|
-
}
|
|
265
294
|
const rows = [];
|
|
266
295
|
try {
|
|
267
296
|
const context = await browser.newContext({ viewport: { width: Math.max(...opts.widths), height: opts.height } });
|
|
@@ -279,7 +308,7 @@ async function main(argv) {
|
|
|
279
308
|
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
|
280
309
|
await page.waitForTimeout(250);
|
|
281
310
|
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
|
282
|
-
const m = await page.evaluate(measure, floor);
|
|
311
|
+
const m = await page.evaluate(measure, [floor, ELEMENT_CAP]);
|
|
283
312
|
rows.push(...verdicts(width, floor, m));
|
|
284
313
|
}
|
|
285
314
|
} catch (err) {
|
|
@@ -287,7 +316,7 @@ async function main(argv) {
|
|
|
287
316
|
} finally {
|
|
288
317
|
await browser.close();
|
|
289
318
|
}
|
|
290
|
-
return report(rows, opts,
|
|
319
|
+
return report(rows, opts, shown);
|
|
291
320
|
}
|
|
292
321
|
|
|
293
322
|
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py
CHANGED
|
@@ -7,7 +7,7 @@ This is an output correctness gate, not authentication, discovery, or a relay.
|
|
|
7
7
|
import json
|
|
8
8
|
import re
|
|
9
9
|
import sys
|
|
10
|
-
from urllib.parse import parse_qs, urlsplit
|
|
10
|
+
from urllib.parse import parse_qs, parse_qsl, unquote, urlsplit
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def require(condition, reason):
|
|
@@ -15,6 +15,63 @@ def require(condition, reason):
|
|
|
15
15
|
raise ValueError(reason)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
# Credential names and values: the same rules as fabric-button.js (credentialName, secretValue).
|
|
19
|
+
CREDENTIAL_WORDS = {
|
|
20
|
+
'token', 'secret', 'password', 'passwd', 'pass', 'passphrase', 'credential', 'credentials', 'cookie',
|
|
21
|
+
'authorization', 'bearer', 'jwt', 'otp', 'apikey', 'signature', 'sig', 'accesstoken', 'idtoken', 'authtoken',
|
|
22
|
+
'refreshtoken', 'sessionid', 'sessiontoken', 'logincode', 'authcode', 'privatekey', 'clientsecret', 'secretkey',
|
|
23
|
+
'accesskey'}
|
|
24
|
+
CREDENTIAL_PAIRS = {
|
|
25
|
+
'key': {'private', 'api', 'access', 'secret', 'session', 'auth', 'signing', 'encryption', 'master', 'client', 'login',
|
|
26
|
+
'refresh'},
|
|
27
|
+
'auth': {'token', 'code', 'header'},
|
|
28
|
+
'session': {'token', 'id'},
|
|
29
|
+
'code': {'login', 'auth', 'access', 'session'}}
|
|
30
|
+
JWT_SHAPE = re.compile(r'eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*')
|
|
31
|
+
SECRET_VALUE = re.compile(r'(^bearer\s.+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}'
|
|
32
|
+
r'|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})', re.I | re.S)
|
|
33
|
+
LONG_RUN = re.compile(r'[A-Za-z0-9+/=_-]{40,}')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def name_words(name):
|
|
37
|
+
name = re.sub(r'([a-z0-9])([A-Z])', r'\1 \2', name)
|
|
38
|
+
name = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1 \2', name)
|
|
39
|
+
return [w.lower() for w in re.split(r'[\s._-]+', name) if w]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def credential_name(name):
|
|
43
|
+
split = name_words(name)
|
|
44
|
+
words = set(split) | {w[:-1] for w in split if len(w) > 2 and w.endswith('s')}
|
|
45
|
+
if words & CREDENTIAL_WORDS:
|
|
46
|
+
return True
|
|
47
|
+
if len(split) == 1 and words & set(CREDENTIAL_PAIRS):
|
|
48
|
+
return True
|
|
49
|
+
return any(w in words and words & pairs for w, pairs in CREDENTIAL_PAIRS.items())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def secret_value(value):
|
|
53
|
+
long_secret = any(re.search(r'[a-z]', run) and re.search(r'[A-Z]', run) and re.search(r'[0-9]', run)
|
|
54
|
+
for run in LONG_RUN.findall(value))
|
|
55
|
+
return bool(JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or long_secret)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def carries_credential(text):
|
|
59
|
+
# A query or fragment whose names or values could be a credential: refused even with an empty value.
|
|
60
|
+
return (secret_value(text) or secret_value(unquote(text))
|
|
61
|
+
or any(credential_name(name) or secret_value(name) or secret_value(value)
|
|
62
|
+
for name, value in parse_qsl(text, keep_blank_values=True)))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def login_path(path):
|
|
66
|
+
# The path a server routes: refuse what normalises differently in different servers, then compare.
|
|
67
|
+
decoded = unquote(path)
|
|
68
|
+
require(not re.search(r'%2f|%5c', path, re.I) and '\\' not in decoded, 'encoded_separator')
|
|
69
|
+
segments = decoded.split('/')[1:]
|
|
70
|
+
require(not any(seg in ('.', '..') for seg in segments), 'dot_segment')
|
|
71
|
+
require(all(seg for seg in segments[:-1]), 'empty_segment')
|
|
72
|
+
return '/'.join([''] + [seg for seg in segments if seg]).lower() == '/fabric/v1/login'
|
|
73
|
+
|
|
74
|
+
|
|
18
75
|
def safe_path(path):
|
|
19
76
|
return (isinstance(path, str) and path.startswith('/') and not path.startswith('//')
|
|
20
77
|
and len(path) <= 2048 and not re.search(r'[\\\x00-\x1f\x7f]', path))
|
|
@@ -42,10 +99,14 @@ def check(data):
|
|
|
42
99
|
and not plain.username and not plain.password, 'invalid_http_fallback')
|
|
43
100
|
http_page = plain.path + ('?' + plain.query if plain.query else '') + ('#' + plain.fragment if plain.fragment else '')
|
|
44
101
|
require(safe_path(http_page) and http_page == (page or '/'), 'mismatched_page')
|
|
45
|
-
sensitive = {'token', 'access_token', 'refresh_token', 'password', 'secret', 'login_code'
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
102
|
+
sensitive = {'token', 'access_token', 'refresh_token', 'password', 'secret', 'login_code', 'code', 'api_key',
|
|
103
|
+
'apikey', 'key', 'auth'}
|
|
104
|
+
parts = urlsplit(http_page)
|
|
105
|
+
places = [parts.query, parts.fragment] + parts.fragment.split('?', 1)[1:]
|
|
106
|
+
require(not any(sensitive.intersection(k.lower() for k in parse_qs(p, keep_blank_values=True)) for p in places),
|
|
107
|
+
'credential_query')
|
|
108
|
+
require(not any(carries_credential(p) for p in places), 'credential_query')
|
|
109
|
+
require(not login_path(parts.path), 'login_link_is_not_dashboard')
|
|
49
110
|
state = host.get('state')
|
|
50
111
|
require(state in ('available', 'not_installed', 'unknown', 'incompatible', 'handler_mismatch', 'unsupported'), 'unknown_host_state')
|
|
51
112
|
target, viewer = data.get('target_device'), data.get('viewer_device')
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -318,7 +318,7 @@ class Probe:
|
|
|
318
318
|
self.events = events
|
|
319
319
|
bad = []
|
|
320
320
|
for e in events:
|
|
321
|
-
if e.get("level") not in fs.LEVELS or not re.match(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)
|
|
321
|
+
if e.get("level") not in fs.LEVELS or not re.match(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*\Z", str(e.get("kind", ""))):
|
|
322
322
|
bad.append(str(e.get("id")))
|
|
323
323
|
elif " " not in str(e.get("text", "")).strip():
|
|
324
324
|
bad.append("%s (text is not a sentence)" % e.get("id"))
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py
CHANGED
|
@@ -29,7 +29,7 @@ def plist_problems(plist: dict, label: object, token: object = None) -> list:
|
|
|
29
29
|
if plist.get("KeepAlive") is not True:
|
|
30
30
|
problems.append("KeepAlive is %r, not true" % plist.get("KeepAlive"))
|
|
31
31
|
for key, value in (plist.get("EnvironmentVariables") or {}).items():
|
|
32
|
-
if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)
|
|
32
|
+
if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)\Z", key) and not key.endswith("_FILE"):
|
|
33
33
|
problems.append("secret-like variable %s in the plist" % key)
|
|
34
34
|
if token and token in str(value):
|
|
35
35
|
problems.append("the service token itself is in the plist")
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py
CHANGED
|
@@ -23,6 +23,7 @@ from pathlib import Path
|
|
|
23
23
|
import re
|
|
24
24
|
import subprocess
|
|
25
25
|
import sys
|
|
26
|
+
import time
|
|
26
27
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
27
28
|
|
|
28
29
|
KIT_DIR = Path(__file__).resolve().parent.parent / "assets" / "dashboard-kit"
|
|
@@ -34,6 +35,9 @@ JS_BEGIN, JS_END = "/* fabric-dashboard-kit:theme-ids:begin */", "/* fabric-dash
|
|
|
34
35
|
TOKEN_ORDER = ("bg", "panel", "panel-2", "ink", "muted", "border", "border-strong", "edge", "accent",
|
|
35
36
|
"accent-weak", "accent-ink", "ok", "ok-weak", "warn", "warn-weak", "danger", "danger-weak",
|
|
36
37
|
"info", "info-weak")
|
|
38
|
+
#: The SessionStart notice's total for every git call and the tree walk together: under the hook's 5 s (FAA-69).
|
|
39
|
+
NOTICE_BUDGET_S = 3.5
|
|
40
|
+
|
|
37
41
|
SKIP_DIRS = {".git", "node_modules", "dist", "build", ".venv", "venv", "__pycache__", ".next", "out"}
|
|
38
42
|
EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
|
|
39
43
|
|
|
@@ -187,27 +191,44 @@ def is_kit_source(path: Path) -> bool:
|
|
|
187
191
|
return False
|
|
188
192
|
|
|
189
193
|
|
|
190
|
-
|
|
194
|
+
class OutOfTime(Exception):
|
|
195
|
+
"""A shared deadline (`until`, a time.monotonic() value) passed before the answer was known."""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def git(repo: Path, *args: str, timeout: float = 2.0, until: Optional[float] = None) -> Optional[str]:
|
|
199
|
+
"""git's stdout, or None. With `until`, the call gets at most what is left of that deadline, and a call the
|
|
200
|
+
deadline cuts short raises OutOfTime instead of answering None."""
|
|
201
|
+
if until is not None:
|
|
202
|
+
left = until - time.monotonic()
|
|
203
|
+
if left <= 0:
|
|
204
|
+
raise OutOfTime()
|
|
205
|
+
timeout = min(timeout, left)
|
|
191
206
|
try:
|
|
192
207
|
done = subprocess.run(["git", "-C", str(repo)] + list(args), capture_output=True, text=True, timeout=timeout)
|
|
208
|
+
except subprocess.TimeoutExpired:
|
|
209
|
+
if until is not None and time.monotonic() >= until - 0.05:
|
|
210
|
+
raise OutOfTime()
|
|
211
|
+
return None
|
|
193
212
|
except (OSError, subprocess.SubprocessError):
|
|
194
213
|
return None
|
|
195
214
|
return done.stdout if done.returncode == 0 else None
|
|
196
215
|
|
|
197
216
|
|
|
198
|
-
def repo_root(start: Path) -> Optional[Path]:
|
|
199
|
-
out = git(start, "rev-parse", "--show-toplevel")
|
|
217
|
+
def repo_root(start: Path, until: Optional[float] = None) -> Optional[Path]:
|
|
218
|
+
out = git(start, "rev-parse", "--show-toplevel", until=until)
|
|
200
219
|
return Path(out.strip()) if out and out.strip() else None
|
|
201
220
|
|
|
202
221
|
|
|
203
|
-
def find_copies(repo: Path) -> List[Path]:
|
|
222
|
+
def find_copies(repo: Path, until: Optional[float] = None) -> List[Path]:
|
|
204
223
|
listed = git(repo, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--",
|
|
205
|
-
"*fabric-dashboard.css", "*fabric-dashboard.js", "*fabric-button.js")
|
|
224
|
+
"*fabric-dashboard.css", "*fabric-dashboard.js", "*fabric-button.js", until=until)
|
|
206
225
|
if listed is not None:
|
|
207
226
|
paths = [repo / p for p in listed.split("\0") if p]
|
|
208
227
|
else:
|
|
209
228
|
paths = []
|
|
210
229
|
for top, dirs, files in os.walk(repo):
|
|
230
|
+
if until is not None and time.monotonic() >= until:
|
|
231
|
+
raise OutOfTime()
|
|
211
232
|
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
|
212
233
|
if len(Path(top).relative_to(repo).parts) > 6:
|
|
213
234
|
dirs[:] = []
|
|
@@ -256,9 +277,9 @@ def same_repository(a: str, b: str) -> bool:
|
|
|
256
277
|
return bool(a and b) and norm(a) == norm(b)
|
|
257
278
|
|
|
258
279
|
|
|
259
|
-
def services_of(repo: Path, directory: Optional[Path] = None) -> List[str]:
|
|
280
|
+
def services_of(repo: Path, directory: Optional[Path] = None, until: Optional[float] = None) -> List[str]:
|
|
260
281
|
"""The services whose descriptor names this repository as its source (`source.repository`)."""
|
|
261
|
-
origin = (git(repo, "config", "--get", "remote.origin.url") or "").strip()
|
|
282
|
+
origin = (git(repo, "config", "--get", "remote.origin.url", until=until) or "").strip()
|
|
262
283
|
if not origin:
|
|
263
284
|
return []
|
|
264
285
|
found = []
|
|
@@ -277,19 +298,24 @@ def services_of(repo: Path, directory: Optional[Path] = None) -> List[str]:
|
|
|
277
298
|
return found
|
|
278
299
|
|
|
279
300
|
|
|
280
|
-
def declares_dashboard(repo: Path) -> bool:
|
|
301
|
+
def declares_dashboard(repo: Path, until: Optional[float] = None) -> bool:
|
|
281
302
|
"""Whether the repository's code declares a dashboard surface (a quick git grep, no network)."""
|
|
282
303
|
out = git(repo, "grep", "--untracked", "-l", "-I", "-E", r"""["']?dashboard["']?[[:space:]]*[:=][[:space:]]*\{?[[:space:]]*["']?path""", "--",
|
|
283
|
-
"*.py", "*.mjs", "*.js", "*.ts", "*.json", timeout=2.0)
|
|
304
|
+
"*.py", "*.mjs", "*.js", "*.ts", "*.json", timeout=2.0, until=until)
|
|
284
305
|
return bool(out and out.strip())
|
|
285
306
|
|
|
286
307
|
|
|
287
|
-
def status(repo: Path, manifest: Optional[Dict[str, Any]] = None, directory: Optional[Path] = None
|
|
308
|
+
def status(repo: Path, manifest: Optional[Dict[str, Any]] = None, directory: Optional[Path] = None,
|
|
309
|
+
services: Optional[List[str]] = None, until: Optional[float] = None) -> Dict[str, Any]:
|
|
310
|
+
"""The kit copies in `repo`, the services it owns and whether it has a dashboard. `services`, when the caller has
|
|
311
|
+
them already; `until`, a shared deadline (OutOfTime when it passes)."""
|
|
288
312
|
manifest = manifest or load_manifest()
|
|
289
|
-
|
|
290
|
-
|
|
313
|
+
if services is None:
|
|
314
|
+
services = services_of(repo, directory, until=until)
|
|
315
|
+
copies = [copy_state(p, manifest) for p in find_copies(repo, until=until)]
|
|
291
316
|
return {"repository": str(repo), "kit": "%s@%s" % (KIT_NAME, manifest["version"]), "copies": copies,
|
|
292
|
-
"services": services,
|
|
317
|
+
"services": services,
|
|
318
|
+
"dashboard": bool(copies) or (bool(services) and declares_dashboard(repo, until=until))}
|
|
293
319
|
|
|
294
320
|
|
|
295
321
|
def notice_line(report: Dict[str, Any], script: Path) -> Optional[str]:
|
|
@@ -364,14 +390,22 @@ def hook_cwd(stdin_text: str) -> Path:
|
|
|
364
390
|
|
|
365
391
|
|
|
366
392
|
def notice() -> int:
|
|
393
|
+
"""The SessionStart hook. Asks first whether the repository owns a service and says nothing, having looked at
|
|
394
|
+
nothing else, when it owns none; every git call and the tree walk share NOTICE_BUDGET_S, and a notice that runs
|
|
395
|
+
out of it says nothing rather than a half-measured line (FAA-69)."""
|
|
396
|
+
until = time.monotonic() + NOTICE_BUDGET_S
|
|
367
397
|
try:
|
|
368
398
|
stdin_text = "" if sys.stdin is None or sys.stdin.isatty() else sys.stdin.read(65536)
|
|
369
|
-
root = repo_root(hook_cwd(stdin_text))
|
|
370
|
-
if root is
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
399
|
+
root = repo_root(hook_cwd(stdin_text), until=until)
|
|
400
|
+
if root is None:
|
|
401
|
+
return 0
|
|
402
|
+
services = services_of(root, until=until)
|
|
403
|
+
if not services:
|
|
404
|
+
return 0
|
|
405
|
+
line = notice_line(status(root, services=services, until=until), Path(__file__).resolve())
|
|
406
|
+
if line:
|
|
407
|
+
print(line)
|
|
408
|
+
except Exception: # noqa: BLE001 - a hook never breaks a session; OutOfTime included
|
|
375
409
|
pass
|
|
376
410
|
return 0
|
|
377
411
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs
CHANGED
|
@@ -228,9 +228,11 @@ export const expiredSessionCookieHeader = (remote = false) => (remote ? remoteSe
|
|
|
228
228
|
*/
|
|
229
229
|
export function checkRemoteRequest(origin, host, requestOrigin, secFetchSite, forwardedProto) {
|
|
230
230
|
const own = new URL(origin);
|
|
231
|
-
|
|
231
|
+
// A default port, written or not, names the same host (finding 15): the URL drops it from `own`; drop it from the request too.
|
|
232
|
+
const dropped = (value) => { const port = { 'https:': ':443', 'http:': ':80' }[own.protocol]; return port && value.endsWith(port) ? value.slice(0, -port.length) : value; };
|
|
233
|
+
if (dropped(String(host ?? '').toLowerCase()) !== own.host) return `Host ${host} is not this service.`;
|
|
232
234
|
if (forwardedProto !== undefined && forwardedProto !== null && String(forwardedProto).split(',')[0].trim() !== 'https') return 'This service answers over https only.';
|
|
233
|
-
if (requestOrigin !== undefined && requestOrigin !== null && requestOrigin !== own.origin) return `Origin ${requestOrigin} is not this service.`;
|
|
235
|
+
if (requestOrigin !== undefined && requestOrigin !== null && dropped(String(requestOrigin)) !== own.origin) return `Origin ${requestOrigin} is not this service.`;
|
|
234
236
|
if (secFetchSite === 'cross-site') return 'Cross-site requests are refused.';
|
|
235
237
|
return null;
|
|
236
238
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The board link (DEC-0078, FAA-12b): the board session over the hub, the fsynced checkpoint and a claimed request as data.
|
|
2
|
+
// Part of the Node board connector (FAA-65, layer L4), below the responder in fabric-board-session.mjs, which re-exports
|
|
3
|
+
// these names; the twin of fabric_board_link.py. Node 18+, no dependency.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { HubError, MCP_PROTOCOL_VERSION, postMcp, toolAnswer } from './fabric-hub.mjs';
|
|
7
|
+
import { atomicWrite, ensurePrivateDir } from './fabric-service.mjs';
|
|
8
|
+
import { BoardError, RESPOND_TOOLS, isObj, refusalProblems } from './fabric-board-shapes.mjs';
|
|
9
|
+
|
|
10
|
+
/** MCP over HTTP with a board session's one-session bearer; calls are serialised; close() sends the session's DELETE. */
|
|
11
|
+
export class BoardSession {
|
|
12
|
+
constructor(grant, { timeout = 15000, post = postMcp } = {}) {
|
|
13
|
+
Object.assign(this, { grant, timeout, post });
|
|
14
|
+
this.mcpSession = null;
|
|
15
|
+
this.protocolVersion = MCP_PROTOCOL_VERSION;
|
|
16
|
+
this.rpc = 0;
|
|
17
|
+
this.queue = Promise.resolve();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#serial(fn) {
|
|
21
|
+
const run = this.queue.then(fn, fn);
|
|
22
|
+
this.queue = run.catch(() => {});
|
|
23
|
+
return run;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async #send(body, method = 'POST') {
|
|
27
|
+
try {
|
|
28
|
+
return await this.post(this.grant.origin, body, this.grant.bearer(), { session: this.mcpSession, timeout: this.timeout, method, protocolVersion: this.mcpSession ? this.protocolVersion : undefined });
|
|
29
|
+
} catch (e) {
|
|
30
|
+
if (e instanceof HubError) throw new BoardError('unreachable', 'Fabric\'s hub did not answer; a write may have been stored, so send it again with the same idempotency key.', true);
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
open() {
|
|
36
|
+
return this.#serial(async () => {
|
|
37
|
+
this.rpc += 1;
|
|
38
|
+
const answer = await this.#send({ jsonrpc: '2.0', id: this.rpc, method: 'initialize', params: { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'fabric-agent-adapter-board', version: '1' } } });
|
|
39
|
+
if (answer.status === 401) throw new BoardError('session-ended', 'Fabric refused the board session\'s bearer; join again.', true);
|
|
40
|
+
const sid = answer.headers['mcp-session-id'];
|
|
41
|
+
if (answer.status !== 200 || !isObj(answer.body) || !isObj(answer.body.result) || !sid) throw new BoardError('unreachable', `Fabric did not open the board session (HTTP ${answer.status}).`, true);
|
|
42
|
+
this.mcpSession = String(sid);
|
|
43
|
+
if (typeof answer.body.result.protocolVersion === 'string') this.protocolVersion = answer.body.result.protocolVersion;
|
|
44
|
+
await this.#send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
call(tool, args) {
|
|
49
|
+
if (!RESPOND_TOOLS.has(tool)) return Promise.reject(new BoardError('not-in-scope', `${tool} is not served to a board session; use a Project session for it.`, false, tool));
|
|
50
|
+
return this.#serial(async () => {
|
|
51
|
+
if (!this.mcpSession) throw new BoardError('session-ended', 'The board session is not open.', true, tool);
|
|
52
|
+
this.rpc += 1;
|
|
53
|
+
const answer = await this.#send({ jsonrpc: '2.0', id: this.rpc, method: 'tools/call', params: { name: tool, arguments: args } });
|
|
54
|
+
if (answer.status === 401) throw new BoardError('session-ended', 'Fabric ended the board session (it left, was revoked or expired); join again.', true, tool);
|
|
55
|
+
let value;
|
|
56
|
+
try {
|
|
57
|
+
value = toolAnswer(answer, tool);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
throw new BoardError(e.retryable ? 'unreachable' : 'receipt-shape', e.message, e.retryable, tool);
|
|
60
|
+
}
|
|
61
|
+
if ('error' in value) {
|
|
62
|
+
if (refusalProblems(value).length) throw new BoardError('receipt-shape', `Fabric refused ${tool} in a form the contract does not name.`, false, tool);
|
|
63
|
+
throw new BoardError(value.error.code, value.error.message, value.error.retryable, tool);
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
close() {
|
|
70
|
+
return this.#serial(async () => {
|
|
71
|
+
if (!this.mcpSession) return;
|
|
72
|
+
try {
|
|
73
|
+
await this.#send({}, 'DELETE');
|
|
74
|
+
} catch {
|
|
75
|
+
// best effort: the lease lapses in 60 s anyway
|
|
76
|
+
}
|
|
77
|
+
this.mcpSession = null;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The responder's journal (0600 JSON, the same file fabric_board.py writes), fsynced before the step it announces. */
|
|
83
|
+
export class Checkpoint {
|
|
84
|
+
constructor(file) {
|
|
85
|
+
this.file = file;
|
|
86
|
+
this.entries = this.#load();
|
|
87
|
+
}
|
|
88
|
+
#load() {
|
|
89
|
+
let text;
|
|
90
|
+
try {
|
|
91
|
+
text = fs.readFileSync(this.file, 'utf8');
|
|
92
|
+
} catch (e) {
|
|
93
|
+
if (e.code === 'ENOENT') return {};
|
|
94
|
+
throw new BoardError('checkpoint', `The board checkpoint ${this.file} cannot be read (${e.code}); fix or move it before answering requests.`);
|
|
95
|
+
}
|
|
96
|
+
let doc;
|
|
97
|
+
try {
|
|
98
|
+
doc = JSON.parse(text);
|
|
99
|
+
} catch {
|
|
100
|
+
doc = null;
|
|
101
|
+
}
|
|
102
|
+
if (!isObj(doc) || doc.version !== 1 || !isObj(doc.requests)) throw new BoardError('checkpoint', `The board checkpoint ${this.file} is not one this kit writes.`);
|
|
103
|
+
return doc.requests;
|
|
104
|
+
}
|
|
105
|
+
#write(entries) {
|
|
106
|
+
try {
|
|
107
|
+
ensurePrivateDir(path.dirname(this.file));
|
|
108
|
+
const sorted = Object.fromEntries(Object.keys(entries).sort().map((k) => [k, entries[k]]));
|
|
109
|
+
atomicWrite(this.file, `${JSON.stringify({ requests: sorted, version: 1 }, null, 1)}\n`, 0o600);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
throw new BoardError('checkpoint', `The board checkpoint could not be written (${e.code ?? e.message}); nothing was sent.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
put(entry) {
|
|
115
|
+
const entries = { ...this.entries, [entry.request]: JSON.parse(JSON.stringify(entry)) };
|
|
116
|
+
this.#write(entries);
|
|
117
|
+
this.entries = entries;
|
|
118
|
+
}
|
|
119
|
+
drop(requestId) {
|
|
120
|
+
if (!(requestId in this.entries)) return;
|
|
121
|
+
const { [requestId]: _gone, ...entries } = this.entries;
|
|
122
|
+
this.#write(entries);
|
|
123
|
+
this.entries = entries;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A claimed request as data: never an instruction. */
|
|
128
|
+
export class BoardRequest {
|
|
129
|
+
constructor(message, capability) {
|
|
130
|
+
const req = isObj(message.request) ? message.request : {};
|
|
131
|
+
const body = isObj(message.body) ? message.body : {};
|
|
132
|
+
this.requestId = req.id;
|
|
133
|
+
this.messageId = message.id;
|
|
134
|
+
this.capability = capability;
|
|
135
|
+
this.body = typeof body.text === 'string' ? body.text : null;
|
|
136
|
+
this.bodyFormat = body.format ?? 'text/plain';
|
|
137
|
+
this.artifacts = Array.isArray(message.artifacts) ? [...message.artifacts] : [];
|
|
138
|
+
this.sender = isObj(message.sender) ? message.sender : {};
|
|
139
|
+
this.deadline = req.deadline;
|
|
140
|
+
this.thread = message.thread;
|
|
141
|
+
}
|
|
142
|
+
toJSON() {
|
|
143
|
+
return { requestId: this.requestId, capability: this.capability }; // never the body
|
|
144
|
+
}
|
|
145
|
+
}
|