@passioncode-ai/fabric-agent-adapter 0.40.0 → 0.42.1

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 (89) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +9 -3
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +28 -30
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +47 -7
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +230 -29
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -2
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +68 -24
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +1 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +196 -188
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +5 -5
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +2 -2
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +6 -2
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -1
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +3 -1
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +48 -14
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +34 -1
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +157 -22
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +7 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +12 -6
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +6 -6
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +17 -4
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +3 -3
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +207 -227
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +4 -4
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +0 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +52 -5
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +101 -45
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +12 -12
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +5 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +13 -10
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -1
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +118 -71
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +272 -74
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +36 -11
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +3 -3
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +270 -35
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  73. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  74. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  75. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  76. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  77. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  78. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +36 -16
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +44 -22
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +54 -6
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +72 -13
  84. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +28 -7
  85. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +277 -49
  86. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +10 -1
  87. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  88. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  89. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -13,15 +13,22 @@
13
13
  // scroll box) and ED-5 (sticky chrome within min(120 px, a quarter of the height), anchors landing
14
14
  // below it, no nested full-height scroller). What it cannot measure is NOT_RUN with the reason,
15
15
  // never a pass. WebKit is the default engine (Safari's). Exit 0 when nothing FAILs, 1 when
16
- // something does, 2 on a usage error. It reads; it never clicks a control of the page.
17
- import { readFileSync, existsSync } from 'node:fs';
16
+ // something does, 2 on a usage error or a report with no rule in it. It reads; it never clicks a control of the page.
17
+ import { readFileSync, existsSync, realpathSync } from 'node:fs';
18
18
  import { createRequire } from 'node:module';
19
19
  import { homedir, platform } from 'node:os';
20
20
  import path from 'node:path';
21
- import { pathToFileURL } from 'node:url';
21
+ import { fileURLToPath, 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 and not clipped by an ancestor,
122
- // or when it sits in a closed <details> whose summary is reachable, or in a menu a visible
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
- if (!clipsX(a)) continue;
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 > 6000) break;
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', problems.length ? 'FAIL' : 'PASS', problems.join('; ') || `${m.boxes.length} overflowing box(es), each keyboard-reachable; edge shades not measured`);
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
- function report(rows, opts, target) {
254
+ export 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');
@@ -236,32 +261,40 @@ function report(rows, opts, target) {
236
261
  for (const r of rows) process.stdout.write(`${r.verdict.padEnd(8)} ${(r.rule + (r.width ? '@' + r.width : '')).padEnd(10)} ${r.evidence}\n`);
237
262
  process.stdout.write(`\n${rows.length} rule(s), ${failed} FAIL, ${rows.filter((r) => r.verdict === 'NOT_RUN').length} NOT_RUN\n`);
238
263
  }
264
+ if (!rows.length) {
265
+ process.stderr.write('no rule ran: nothing was measured, so nothing passed\n');
266
+ return 2;
267
+ }
239
268
  return failed ? 1 : 0;
240
269
  }
241
270
 
242
271
  async function main(argv) {
243
272
  const opts = parseArgs(argv);
273
+ // What the report names: the service, never the login URL its one-time code is in.
274
+ const shown = opts.service || opts.url;
244
275
  let target = opts.url;
245
276
  let floor = opts.floor;
246
- const notRun = (why) => report(RULES.map((rule) => ({ rule, width: 0, verdict: 'NOT_RUN', evidence: why })), opts, target || opts.service);
277
+ const notRun = (why) => report(RULES.map((rule) => ({ rule, width: 0, verdict: 'NOT_RUN', evidence: why })), opts, shown);
278
+ // The browser first: a run that cannot measure must not spend a one-time code.
279
+ const pw = await loadPlaywright(opts.playwright);
280
+ if (!pw) return notRun('Playwright is not installed here: npm i -D playwright && npx playwright install webkit (or pass --playwright DIR)');
281
+ let browser;
282
+ try {
283
+ browser = await pw[opts.browser].launch();
284
+ } catch (err) {
285
+ return notRun(`the ${opts.browser} browser does not start: ${String(err.message).split('\n')[0]} (npx playwright install ${opts.browser})`);
286
+ }
247
287
  if (opts.service) {
248
288
  try {
249
289
  const t = await serviceTarget(opts.service, opts.servicesDir);
250
290
  target = t.url;
251
291
  if (floor === undefined && Number.isInteger(t.declared?.minWidth)) floor = t.declared.minWidth;
252
292
  } catch (err) {
293
+ await browser.close();
253
294
  return notRun(`could not open the service's dashboard: ${err.message}`);
254
295
  }
255
296
  }
256
297
  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
298
  const rows = [];
266
299
  try {
267
300
  const context = await browser.newContext({ viewport: { width: Math.max(...opts.widths), height: opts.height } });
@@ -279,7 +312,7 @@ async function main(argv) {
279
312
  await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
280
313
  await page.waitForTimeout(250);
281
314
  await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
282
- const m = await page.evaluate(measure, floor);
315
+ const m = await page.evaluate(measure, [floor, ELEMENT_CAP]);
283
316
  rows.push(...verdicts(width, floor, m));
284
317
  }
285
318
  } catch (err) {
@@ -287,9 +320,20 @@ async function main(argv) {
287
320
  } finally {
288
321
  await browser.close();
289
322
  }
290
- return report(rows, opts, opts.service || target);
323
+ return report(rows, opts, shown);
324
+ }
325
+
326
+ // Whether node was started on this file. Real paths, not URLs: the launcher installs the plugin under a symlinked
327
+ // folder (~/.passioncode/current), and comparing import.meta.url (resolved) with argv[1] (the link) never ran main
328
+ // (board thread b42e16b7).
329
+ function startedDirectly() {
330
+ try {
331
+ return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
332
+ } catch {
333
+ return false;
334
+ }
291
335
  }
292
336
 
293
- if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
337
+ if (startedDirectly()) {
294
338
  main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { process.stderr.write(String(err?.stack || err) + '\n'); process.exit(2); });
295
339
  }
@@ -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
- page_query = parse_qs(urlsplit(http_page).query)
47
- require(not sensitive.intersection(k.lower() for k in page_query), 'credential_query')
48
- require(urlsplit(http_page).path != '/fabric/v1/login', 'login_link_is_not_dashboard')
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')
@@ -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_]*)*$", str(e.get("kind", ""))):
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"))
@@ -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)$", key) and not key.endswith("_FILE"):
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")
@@ -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
- def git(repo: Path, *args: str, timeout: float = 2.0) -> Optional[str]:
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) -> Dict[str, Any]:
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
- copies = [copy_state(p, manifest) for p in find_copies(repo)]
290
- services = services_of(repo, directory)
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, "dashboard": bool(copies) or (bool(services) and declares_dashboard(repo))}
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 not None:
371
- line = notice_line(status(root), Path(__file__).resolve())
372
- if line:
373
- print(line)
374
- except Exception: # noqa: BLE001 — a hook never breaks a session
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
 
@@ -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
- if (String(host ?? '').toLowerCase() !== own.host) return `Host ${host} is not this service.`;
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
  }
@@ -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
+ }