@passioncode-ai/passioncode 0.2.38 → 0.2.40

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 (73) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +29 -11
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +58 -5
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +11 -3
  10. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  11. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +8 -6
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +50 -12
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +5 -4
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +5 -3
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +3 -1
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +3 -2
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +58 -5
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +13 -4
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +13 -1
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +60 -0
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +159 -2
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +8 -2
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +4 -2
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +14 -2
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +2 -1
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +40 -3
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +8 -6
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +67 -2
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +371 -2
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +170 -0
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +275 -0
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +3 -1
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +151 -0
  37. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +38 -8
  38. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +31 -2
  39. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +6 -0
  40. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +68 -3
  41. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +31 -1
  42. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +247 -0
  43. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-page-links.mjs +144 -0
  44. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +22 -1
  45. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +2 -2
  46. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
  47. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +30 -2
  48. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -4
  49. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +209 -0
  50. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +340 -0
  51. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +2 -1
  52. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +170 -0
  53. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +36 -12
  54. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +36 -3
  55. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +7 -0
  56. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +242 -11
  57. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +70 -2
  58. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +324 -0
  59. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_page_links.py +559 -0
  60. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +21 -0
  61. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +2 -2
  62. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +9 -0
  63. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +35 -0
  64. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +6 -4
  65. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +413 -25
  66. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -11
  67. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +58 -5
  68. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +11 -3
  69. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +8 -6
  70. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +87 -10
  71. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +12 -0
  72. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
  73. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -6,6 +6,7 @@
6
6
  options: --services-dir DIR --json --skip-login
7
7
  --ca-file PEM --connect HOST:PORT (remote placement: trust a test CA; dial another address)
8
8
  --board-kit PATH (board.responder: the service's own fabric_board.py or fabric-board.mjs)
9
+ --repo PATH (dashboard.opens-in-host: the service's checkout)
9
10
 
10
11
  A remote placement (DEC-0019 — an online agent or dashboard at an https origin) is probed over
11
12
  TLS with the certificate verified: the well-known document must refuse a request without the
@@ -26,6 +27,16 @@ well-known `protocols` declares that it answers board requests. The rule checks
26
27
  runs the kit's fixture conversation (`fabric_board.self_check`, or `selfCheck` of a Node copy given with --board-kit):
27
28
  the whole conversation, a fenced accept and a board session without an epoch, with no Fabric. A live board is never
28
29
  touched; that smoke needs a disposable Fabric and is reported separately.
30
+
31
+ Actions (fabric-actions/0.1, DEC-0121): `well-known.actions` holds a declared extensions/actions/0.1 to FAC-SEM-142,
32
+ `interop.action-tools` fails a declared action whose tool tools/list does not serve (and a local service that declares
33
+ actions with no MCP surface; a remote one there is NOT_RUN, its twin may be on A2A), and `dashboard.actions-declared`
34
+ is NOT_RUN, never FAIL, for a dashboard that declares none (a read-only dashboard cannot declare an empty list).
35
+
36
+ dashboard.opens-in-host (FAA-118, the operator rule of 2026-10-24): the service's checkout (--repo, else the one of
37
+ the descriptor's source.repository that holds its commands, manifest or launchd job) opens the service's pages only
38
+ through Fabric Dashboards. Tracked text files only, within a time budget; a browser opener aimed at its pages outside
39
+ the confirmed not_installed fallback is a FAIL (the matcher: check_service_rules.py, "dashboard.opens-in-host").
29
40
  """
30
41
 
31
42
  from __future__ import annotations
@@ -69,6 +80,10 @@ from check_service_rules import _semver # noqa: E402,F401 - moved (FAA-65)
69
80
  from check_service_rules import usage_problems # noqa: E402,F401 - moved (FAA-65)
70
81
  from check_service_rules import same_repository # noqa: E402,F401 - moved (FAA-65)
71
82
  from check_service_rules import merge_headers, session_cookie # noqa: E402,F401 - FAA-115
83
+ from check_service_rules import well_known_actions_verdict, action_tools_verdict, actions_declared_verdict # noqa: E402 - DEC-0121
84
+ from check_service_rules import opens_in_host_verdict, service_checkout # noqa: E402 - FAA-118
85
+ from check_service_rules import well_known_a2a_verdict, a2a_card_verdict # noqa: E402 - DEC-0122
86
+ import fabric_a2a as fa2 # noqa: E402 - DEC-0122
72
87
 
73
88
 
74
89
  class _PinnedHTTPS(http.client.HTTPSConnection):
@@ -85,9 +100,13 @@ class _PinnedHTTPS(http.client.HTTPSConnection):
85
100
 
86
101
 
87
102
  class Probe:
103
+ remote = False # a probe built without __init__ (the tests' Probe.__new__) reads as local
104
+
88
105
  def __init__(self, descriptor_path: Path, descriptor: Dict[str, Any], services_dir: Path, skip_login: bool,
89
- ca_file: Optional[str] = None, connect: Optional[str] = None, board_kit: Optional[str] = None):
106
+ ca_file: Optional[str] = None, connect: Optional[str] = None, board_kit: Optional[str] = None,
107
+ repo: Optional[str] = None):
90
108
  self.board_kit = board_kit
109
+ self.repo = repo
91
110
  self.remote = fs.placement_of(descriptor) == "remote"
92
111
  self.ca_file = ca_file
93
112
  self.connect = connect
@@ -256,6 +275,10 @@ class Probe:
256
275
  self.add("well-known.connections", "FAIL" if view["reported"] else "PASS",
257
276
  "; ".join(f["message"] for f in fcn.connections_rules(wk)[:5]) or
258
277
  "%d connection(s), %d named in degraded[]" % (len(view["connections"]), failing))
278
+ # DEC-0121, FAC-SEM-142: every button's agent twin, declared; no declaration is NOT_RUN (D10).
279
+ self.add("well-known.actions", *well_known_actions_verdict(wk))
280
+ # DEC-0122: where the public Agent Card is; no member is NOT_RUN.
281
+ self.add("well-known.a2a", *well_known_a2a_verdict(wk))
259
282
  self.configuration_rule()
260
283
  svc = wk.get("service") or {}
261
284
  answered = "%s.%s" % (svc.get("id"), svc.get("instance"))
@@ -403,6 +426,23 @@ class Probe:
403
426
  self.add("usage.report", "FAIL" if problems else "PASS",
404
427
  "; ".join(problems[:5]) if problems else "%d day(s), unknown cost kept null" % len(days or []))
405
428
 
429
+ # A2A (DEC-0122) ---------------------------------------------------------------
430
+ def a2a_rules(self) -> None:
431
+ """`a2a.card`: the public card read with no Authorization header and never the descriptor's token (D5); its
432
+ interfaces are never called. The verdict is check_service_rules.a2a_card_verdict."""
433
+ view = fa2.a2a_view(self.wk)
434
+ status, body, error = 0, b"", None
435
+ if view["valid"]:
436
+ # Non-ASCII and control characters the schema's path admits are sent percent-encoded.
437
+ target = urllib.parse.quote(view["path"], safe="".join(chr(c) for c in range(0x21, 0x7F)))
438
+ try:
439
+ status, _, body = self.request("GET", target) # Host only: no token header is added here
440
+ except (OSError, ssl.SSLError, http.client.HTTPException) as exc:
441
+ error = str(exc) or type(exc).__name__
442
+ header, _ = fs.token_header(self.d.get("auth"), "")
443
+ self.add("a2a.card", *a2a_card_verdict(self.wk, status, body, remote=self.remote, origin=str(self.d.get("origin") or ""),
444
+ descriptor_header=header, error=error))
445
+
406
446
  # dashboard (fabric-dashboard/0.1, DEC-0036) --------------------------------
407
447
  def dashboard_rules(self) -> None:
408
448
  wk = self.wk or {}
@@ -428,6 +468,24 @@ class Probe:
428
468
  note += " (kit %s is current: dashboard_kit.py status)" % current
429
469
  self.add("dashboard.declared", "PASS", note + "; the page itself: check_dashboard_layout.mjs")
430
470
 
471
+ def dashboard_page_rules(self) -> None:
472
+ """DEC-0121 and FAA-118: what a person can do on the dashboard, and where its pages open."""
473
+ self.add("dashboard.actions-declared", *actions_declared_verdict(self.wk or {})) # D10: never a FAIL for absence
474
+ self.opens_in_host_rule()
475
+
476
+ def opens_in_host_rule(self) -> None:
477
+ """FAA-118: the checkout opens the service's pages only through Fabric Dashboards (decision D13)."""
478
+ dashboard = ((self.wk or {}).get("surfaces") or {}).get("dashboard")
479
+ if not isinstance(dashboard, dict):
480
+ self.add("dashboard.opens-in-host", "NOT_RUN", "no surfaces.dashboard: no page to open")
481
+ return
482
+ checkout, why = service_checkout(self.d, self.repo)
483
+ if checkout is None:
484
+ self.add("dashboard.opens-in-host", "NOT_RUN", why)
485
+ return
486
+ self.add("dashboard.opens-in-host", *opens_in_host_verdict(checkout, 0 if self.remote else self.port,
487
+ str(self.d.get("origin") or ""), str(dashboard.get("path") or "/")))
488
+
431
489
  def login_rules(self) -> None:
432
490
  dashboard = ((self.wk or {}).get("surfaces") or {}).get("dashboard")
433
491
  if not dashboard or not dashboard.get("login"):
@@ -544,11 +602,14 @@ class Probe:
544
602
  else "no readable token")
545
603
  for rule in ("interop.output-schema-object", "interop.tools-match", "interop.job-tools", "interop.unknown-job", "interop.trace-propagation"):
546
604
  self.add(rule, "NOT_RUN", reason)
605
+ self.add("interop.action-tools", *action_tools_verdict(self.wk, None, reason, no_surface=not mcp_surface,
606
+ remote=self.remote))
547
607
  else:
548
608
  try:
549
609
  listing = self.mcp_call("tools/list", {})
550
610
  except (OSError, ValueError) as exc:
551
611
  self.add("interop.tools-match", "FAIL", "tools/list failed: %s" % exc)
612
+ self.add("interop.action-tools", *action_tools_verdict(self.wk, None, "tools/list failed: %s" % exc))
552
613
  listing = None
553
614
  if listing is not None:
554
615
  sent = self.sent_traceparent
@@ -556,6 +617,7 @@ class Probe:
556
617
  self.object_root_rule(tools)
557
618
  self.tools_match_rule(capabilities, manifest_path, tools)
558
619
  self.job_tools_rule(capabilities, tools)
620
+ self.add("interop.action-tools", *action_tools_verdict(self.wk, tools)) # DEC-0121 AP-1: the live twin
559
621
  self.unknown_job_rule(list(tools))
560
622
  self.trace_rule(listing, sent)
561
623
  self.events_trace_rule(self.events)
@@ -810,7 +872,9 @@ class Probe:
810
872
  self.network_rules()
811
873
  self.auth_rules()
812
874
  self.usage_rules()
875
+ self.a2a_rules()
813
876
  self.dashboard_rules()
877
+ self.dashboard_page_rules()
814
878
  self.login_rules()
815
879
  self.interop_rules()
816
880
  verdict, evidence = board_responder_verdict(self.wk, self.board_kit)
@@ -838,6 +902,7 @@ def main(argv: Optional[List[str]] = None) -> int:
838
902
  parser.add_argument("--ca-file", help="remote placement: trust this CA bundle instead of the system store (tests)")
839
903
  parser.add_argument("--connect", help="remote placement: dial HOST:PORT while speaking TLS to the origin's name (tests)")
840
904
  parser.add_argument("--board-kit", help="board.responder: the service's own fabric_board.py or fabric-board.mjs")
905
+ parser.add_argument("--repo", help="dashboard.opens-in-host: the service's checkout (default: found by source.repository)")
841
906
  args = parser.parse_args(argv)
842
907
  if not args.target and not args.descriptor:
843
908
  parser.print_usage(sys.stderr)
@@ -849,7 +914,7 @@ def main(argv: Optional[List[str]] = None) -> int:
849
914
  except (OSError, ValueError) as exc:
850
915
  print("No readable descriptor at %s: %s" % (path, exc), file=sys.stderr)
851
916
  return 1
852
- results = Probe(path, descriptor, services_dir, args.skip_login, args.ca_file, args.connect, args.board_kit).run()
917
+ results = Probe(path, descriptor, services_dir, args.skip_login, args.ca_file, args.connect, args.board_kit, args.repo).run()
853
918
  failed = sum(r["verdict"] == "FAIL" for r in results)
854
919
  if args.json:
855
920
  print(json.dumps({"descriptor": str(path), "results": results, "failed": failed}, indent=2))
@@ -1,6 +1,7 @@
1
1
  """The probe's rules that read one thing and need no running service (FAA-65): a plist's marker and priority, the
2
- network guard's verdict, the board responder's fixture conversation, a session cookie's lifetime, a usage report, and
3
- whether two repository URLs name one repository. ``check_service.py`` runs them against a live service.
2
+ network guard's verdict, the board responder's fixture conversation, a session cookie's lifetime, a usage report,
3
+ whether two repository URLs name one repository, a service's actions declaration (DEC-0121) and whether its checkout
4
+ opens its pages in a browser (FAA-118), and its A2A member and public Agent Card (DEC-0122). ``check_service.py`` runs them against a live service.
4
5
  Standard library only, Python 3.9+."""
5
6
  from __future__ import annotations
6
7
 
@@ -17,6 +18,8 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple
17
18
 
18
19
  sys.path.insert(0, str(Path(__file__).resolve().parent))
19
20
  import fabric_service as fs # noqa: E402 - beside this file
21
+ import fabric_actions as fa # noqa: E402 - beside this file (DEC-0121)
22
+ import fabric_a2a as fa2 # noqa: E402 - beside this file (DEC-0122)
20
23
 
21
24
 
22
25
  def plist_problems(plist: dict, label: object, token: object = None) -> list:
@@ -291,3 +294,369 @@ def same_repository(a: str, b: str) -> bool:
291
294
  u = re.sub(r"^[^@/]+@", "", u)
292
295
  return re.sub(r"\.git$", "", u).rstrip("/")
293
296
  return bool(a and b) and norm(a) == norm(b)
297
+
298
+
299
+ # actions (fabric-actions/0.1, DEC-0121, FAC-SEM-142) ----------------------------------------
300
+ _ACTIONS_KEY_PATH = "/extensions/" + fa.ACTIONS_EXTENSION_KEY.replace("~", "~0").replace("/", "~1")
301
+
302
+
303
+ def _actions_place(instance_path: str) -> str:
304
+ """A finding's place inside the declaration (`/actions/1/id`), or the declaration itself."""
305
+ inner = instance_path[len(_ACTIONS_KEY_PATH):] if instance_path.startswith(_ACTIONS_KEY_PATH) else instance_path
306
+ return inner or "extensions/actions/0.1"
307
+
308
+
309
+ def well_known_actions_verdict(wk: Any) -> Tuple[str, str]:
310
+ """`well-known.actions`: a declared extensions/actions/0.1 is FAC-SEM-142's (the kit's actions_extension_rules, the
311
+ contract's reader): its schema's, each id once, humanStep exactly when gate is human, every tool in a present
312
+ surfaces.mcp.capabilities. No declaration is NOT_RUN: dashboard.actions-declared says what that means."""
313
+ view = fa.actions_view(wk)
314
+ if not view["declared"]:
315
+ return "NOT_RUN", "no extensions/actions/0.1 declaration (DEC-0121 AP-1)"
316
+ findings = fa.actions_extension_rules(wk)
317
+ if findings:
318
+ return "FAIL", "; ".join("FAC-SEM-142 %s: %s" % (_actions_place(f["instancePath"]), f["message"]) for f in findings[:5])
319
+ gates: Dict[str, int] = {}
320
+ for action in view["actions"]:
321
+ gates[action["gate"]] = gates.get(action["gate"], 0) + 1
322
+ return "PASS", "%d action(s) (%s), FAC-SEM-142 holds" % (
323
+ len(view["actions"]), ", ".join("%d %s" % (n, g) for g, n in sorted(gates.items())))
324
+
325
+
326
+ def action_tools_verdict(wk: Any, tools: Optional[Dict[str, Any]], why_not: str = "tools/list was not read",
327
+ no_surface: bool = False, remote: bool = False) -> Tuple[str, str]:
328
+ """`interop.action-tools`: every declared action's tool is served by tools/list, the live surface (FAC-SEM-142
329
+ checks only the list the document names). `tools` None means no listing was read; `why_not` says why. A local
330
+ service that declares actions and has no MCP surface fails: every action has a tool on its agent surface
331
+ (DEC-0121 AP-1), and a local service's is MCP. A remote agent's surface may be A2A or MCP behind a gateway (the
332
+ contract's surfaces table), so there it is NOT_RUN. This goes beyond FAC-SEM-142, which reads only a present list."""
333
+ view = fa.actions_view(wk)
334
+ if not view["declared"]:
335
+ return "NOT_RUN", "no extensions/actions/0.1 declaration"
336
+ if not view["valid"]:
337
+ return "NOT_RUN", "the actions declaration is not readable (well-known.actions): a reader treats it as not declared"
338
+ if no_surface and remote:
339
+ return "NOT_RUN", ("%d action(s) declared and no MCP surface: a remote agent's twin may be on A2A or behind a "
340
+ "gateway, which this probe does not read" % len(view["actions"]))
341
+ if no_surface:
342
+ return "FAIL", ("%d action(s) declared but the service has no MCP surface: buttons without an agent twin "
343
+ "(DEC-0121 AP-1)" % len(view["actions"]))
344
+ if tools is None:
345
+ return "NOT_RUN", why_not
346
+ missing = ["%s (%s)" % (a["tool"], a["id"]) for a in view["actions"] if a["tool"] not in tools]
347
+ if missing:
348
+ return "FAIL", ("tools/list does not serve %s: a button without an agent twin (DEC-0121 AP-1)" % ", ".join(missing))
349
+ return "PASS", "%d declared action(s), every tool served by tools/list" % len(view["actions"])
350
+
351
+
352
+ def actions_declared_verdict(wk: Any) -> Tuple[str, str]:
353
+ """`dashboard.actions-declared` (decision D10): a dashboard whose declaration FAC-SEM-142 accepts passes, one it
354
+ finds (unreadable, a repeated id, a step that disagrees with the gate, a tool the surface does not list) fails,
355
+ and one with none is NOT_RUN, never FAIL: a read-only dashboard cannot declare an empty list
356
+ (minItems 1), so absence proves no page-only button. Fabric Dashboards reports it (CT-126)."""
357
+ surfaces = wk.get("surfaces") if isinstance(wk, dict) else None
358
+ if not (isinstance(surfaces, dict) and surfaces.get("dashboard")):
359
+ return "NOT_RUN", "no surfaces.dashboard"
360
+ view = fa.actions_view(wk)
361
+ if not view["declared"]:
362
+ return "NOT_RUN", ("a dashboard with no extensions/actions/0.1 declaration: a read-only dashboard declares none "
363
+ "(the list needs minItems 1), so its absence proves no page-only button; a dashboard with a "
364
+ "button declares it and its agent twin (DEC-0121 AP-1). Fabric Dashboards reports it (CT-126)")
365
+ if not view["valid"]:
366
+ return "FAIL", "the dashboard's actions declaration is not readable, so a host treats it as not declared (well-known.actions)"
367
+ findings = fa.actions_extension_rules(wk)
368
+ if findings:
369
+ return "FAIL", "the dashboard's actions declaration breaks FAC-SEM-142 in %d place(s) (well-known.actions)" % len(findings)
370
+ return "PASS", "the dashboard declares %d action(s) with their tools (DEC-0121 AP-1)" % len(view["actions"])
371
+
372
+
373
+
374
+ # A2A (DEC-0122; decisions D4 to D6 of the 0.51.0 brief) -------------------------------------------------------------
375
+ def well_known_a2a_verdict(wk: Any) -> Tuple[str, str]:
376
+ """`well-known.a2a`: a present `surfaces.a2a` is the schema's (`{path, protocolVersion: "1.0"}`, nothing else). No
377
+ member is NOT_RUN: the member is a SHOULD for a service that serves a card, and nothing says this one does."""
378
+ view = fa2.a2a_view(wk)
379
+ if not view["declared"]:
380
+ return "NOT_RUN", "no surfaces.a2a: the service names no A2A Agent Card (DEC-0122)"
381
+ if not view["valid"]:
382
+ return "FAIL", "; ".join("%s %s: %s" % (f["instancePath"], f["keyword"], f["message"]) for f in view["findings"][:5])
383
+ return "PASS", "the public Agent Card at %s, A2A %s" % (view["path"], view["protocolVersion"])
384
+
385
+
386
+ def a2a_card_verdict(wk: Any, status: int, body: bytes, *, remote: bool, origin: str, descriptor_header: str,
387
+ error: Optional[str] = None) -> Tuple[str, str]:
388
+ """`a2a.card`: the public card, fetched at `surfaces.a2a.path` with no Authorization header and no descriptor
389
+ token (D5; the caller sends none). On a local placement it answers 200 with a JSON card; on a remote one 401 or
390
+ 403 is a card that is not open, NOT_RUN, never a failure (DEC-0122 item 2: a host and a probe never report a
391
+ refusal as nonconformance). A card read is judged by fabric_a2a.agent_card_findings (D5, D6). The card's interfaces
392
+ are never called."""
393
+ view = fa2.a2a_view(wk)
394
+ if not view["declared"]:
395
+ return "NOT_RUN", "no surfaces.a2a: no card to read"
396
+ if not view["valid"]:
397
+ return "NOT_RUN", "surfaces.a2a is not readable (well-known.a2a): a host does not know where the card is"
398
+ where = "%s without the token" % view["path"]
399
+ if error is not None:
400
+ return "FAIL", "no answer at %s: %s" % (where, error)
401
+ if remote and status in (401, 403):
402
+ return "NOT_RUN", ("HTTP %d at %s: a card that is not open, gated by the service's own credentials or an "
403
+ "identity proxy; a refusal is not nonconformance (DEC-0122 item 2)" % (status, where))
404
+ if status != 200:
405
+ return "FAIL", ("HTTP %d at %s: %s" % (status, where, "the public card answers 200" if remote else
406
+ "on a local placement the public card answers without authentication (DEC-0122 item 2)"))
407
+ try:
408
+ card = json.loads(body)
409
+ except (ValueError, RecursionError):
410
+ return "FAIL", "the answer at %s is not JSON" % where
411
+ findings = fa2.agent_card_findings(card, origin=origin or None, remote=remote, descriptor_header=descriptor_header)
412
+ if findings:
413
+ return "FAIL", "; ".join("%s: %s" % (f["code"], f["message"]) for f in findings[:5]) + (
414
+ " (%d more)" % (len(findings) - 5) if len(findings) > 5 else "")
415
+ return "PASS", "%d interface(s), A2A %s, read at %s; no process member, secret or descriptor token scheme" % (
416
+ len(card["supportedInterfaces"]), fa2.A2A_PROTOCOL_VERSION, where)
417
+
418
+ # dashboard.opens-in-host (FAA-118; the operator rule of 2026-10-24; decision D13) ----------------------------------
419
+ # A service's own pages open only through Fabric Dashboards: the `open_link` of the fabric-dashboards MCP `link` tool,
420
+ # opened with the host's `open` and `fallback=never`; a browser only on a confirmed `not_installed` host. The rule reads
421
+ # the service's checkout, tracked text files only (`git grep -I`, no untracked file, no binary), within a time budget,
422
+ # and reports a line that opens a browser at the service's pages. The matcher, kept precise on purpose:
423
+ #
424
+ # 1. An opener (`_OPENERS`): macOS `open -a <browser>` / `open -b <browser bundle id>` / `open <http(s) URL>` /
425
+ # `open $<...url|link|origin|dashboard|page...>`; `xdg-open`, `gio open`, `sensible-browser`, `x-www-browser`,
426
+ # `wslview`; Windows `start [""] <URL>`, `explorer <URL>`, `url.dll,FileProtocolHandler`; Python
427
+ # `webbrowser.open|open_new|open_new_tab|get(`; Electron `openExternal(`; a process call whose argv starts with one
428
+ # of those programs (`subprocess.run(["open", ...])`, `spawn("xdg-open", ...)`); and in a JavaScript or TypeScript
429
+ # file that imports the npm package `open`, a call of the name it was bound to (`open(...)`, never `x.open(...)`).
430
+ # Python's builtin `open(` and a page's `window.open(` are files and tabs, never an opener.
431
+ # 2. Aimed at the service's pages: in code, the opener's line or one of the 5 lines before it (where a URL is built)
432
+ # names a loopback host (127.0.0.1, localhost, [::1], 0.0.0.0), the service's port as a whole number, its origin's
433
+ # host, its dashboard path (when it is not just "/") or its login path; or the opener's line names an identifier
434
+ # built on "dashboard" (`dashboard_url`, `openDashboard`; never the host app's name "Fabric Dashboards"). In prose
435
+ # the aim is on the opener's own line. An opener with no such aim (a docs site, a report file) is not this rule's.
436
+ # 3. In prose (Markdown and the like) only code counts: a fenced block, or an inline code span (`open -a Safari ...`);
437
+ # "then open `http://localhost:3000`" is a sentence for a person. A prose line that forbids it (never, not, avoid,
438
+ # instead of, ...) is not a finding either.
439
+ # 4. Not a finding: the host's own route (`fabric-dashboards://`, the "Fabric Dashboards" app, `open_link`,
440
+ # `native_link` on the line); the allowed fallback (`not_installed` on the line or the 5 before, or the gate's
441
+ # `browser_link` on the line); a comment line in code; and test, spec and fixture files, which plant raw opens on
442
+ # purpose.
443
+ _BROWSERS = (r"(?:Safari(?: Technology Preview)?|Google Chrome(?: Canary| Dev| Beta)?|Chrome|Chromium|"
444
+ r"Firefox(?: Developer Edition| Nightly)?|Arc|Dia|Brave Browser|Brave|Microsoft Edge|Opera|Vivaldi|Orion|Zen)")
445
+ _BUNDLES = (r"(?:com\.apple\.Safari\w*|com\.google\.Chrome\w*|org\.chromium\.Chromium|org\.mozilla\.firefox\w*|"
446
+ r"company\.thebrowser\.\w+|com\.brave\.Browser\w*|com\.microsoft\.edgemac\w*|com\.operasoftware\.Opera\w*|"
447
+ r"com\.vivaldi\.Vivaldi|com\.kagi\.kagimacOS)")
448
+ _PROGRAMS = r"(?:open|xdg-open|wslview|sensible-browser|x-www-browser|gio|start|explorer(?:\.exe)?)"
449
+ #: Where a shell command starts: a line, a separator, `$(`, a Makefile's `@`, `then`, `do`, `else` or cmd's `/c`, or
450
+ #: right after an opening quote or backtick (a string a process call runs). `playwright-cli open <url>` is that tool's
451
+ #: own subcommand, and `--proxy "http://..." open` an argument after a closing quote.
452
+ _CMD = r"(?:(?:^|[;&|({@]|\$\(|\bthen\b|\bdo\b|\belse\b|/[cC]\b)\s*|[`'\"])"
453
+ _OPENERS = re.compile("|".join((
454
+ _CMD + r"open\s+(?:-[a-zA-Z]+\s+)*-a\s+[\"']?" + _BROWSERS + r"(?![\w-])",
455
+ _CMD + r"open\s+(?:-[a-zA-Z]+\s+)*-b\s+[\"']?" + _BUNDLES,
456
+ _CMD + r"open\s+(?:-[gnF]\s+)*[\"'`]?(?:https?://|\$\{?\w*(?i:url|link|origin|dashboard|page)\w*)",
457
+ r"(?<![\w.$/-])(?:xdg-open|gio\s+open|sensible-browser|x-www-browser|wslview)(?![\w-])",
458
+ _CMD + r"start\s+(?:\"\"\s+)?[\"']?https?://",
459
+ _CMD + r"explorer(?:\.exe)?\s+[\"']?https?://",
460
+ r"url\.dll,\s*FileProtocolHandler",
461
+ r"\bwebbrowser\.(?:open|open_new|open_new_tab|get)\(", # a call, never "webbrowser.open (the default)" in prose
462
+ r"\bopenExternal\(",
463
+ r"(?:spawn|spawnSync|execFile|execFileSync|exec|execSync|Popen|run|call|check_call|check_output)\s*\(\s*\[?\s*"
464
+ r"[\"'`]" + _PROGRAMS + r"[\"'`]\s*[,)\]]",
465
+ )))
466
+ _OPEN_PACKAGE = re.compile(r"""import\s+(\w+)\s+from\s+["']open["']|(?:const|let|var)\s+(\w+)\s*=\s*require\(\s*["']open["']\s*\)""")
467
+ _JS_FILES = (".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx")
468
+ _PROSE_FILES = (".md", ".markdown", ".mdx", ".rst", ".txt", ".adoc", ".org")
469
+ _LOOPBACK = re.compile(r"(?:127\.0\.0\.1|\blocalhost\b|\[::1\]|0\.0\.0\.0)(?::(\d{1,5}))?")
470
+ _HOST_ROUTE = re.compile(r"fabric-dashboards://|Fabric Dashboards|\b(?:open_link|openLink|native_link|nativeLink)\b")
471
+ _FALLBACK = re.compile(r"\bnot[_-]installed\b|\bnotInstalled\b|\bNOT_INSTALLED\b")
472
+ _GATE_LINK = re.compile(r"\b(?:browser_link|browserLink)\b")
473
+ _NEGATION = re.compile(r"(?i)\b(?:never|not|don't|doesn't|avoid|instead of|forbidden|refused?|wrong|no longer|nonconformant)\b")
474
+ _COMMENT = re.compile(r"\s*(?:#(?!!)|//|/\*|\*|<!--|REM\s|::)")
475
+ _TEST_PATH = re.compile(r"(?:^|/)(?:tests?|__tests__|specs?|fixtures|testdata|e2e)/|(?:^|/)(?:test_[^/]*\.py|[^/]*_test\.\w+|"
476
+ r"[^/]*\.(?:test|spec)\.\w+|conftest\.py)\Z")
477
+ _WINDOW = 5 # lines before an opener that may hold its aim or the not_installed branch
478
+ OPENS_IN_HOST_BUDGET = 3.0 # seconds for git and reading, as the session-start notice budgets its own git calls
479
+ _MAX_CANDIDATES = 2000
480
+ _MAX_FILE_BYTES = 1 << 20
481
+ _COARSE = (r"open[[:space:]]+-[ab][[:space:]]|open[[:space:]]+[\"'`]?(https?://|\$)|xdg-open|gio[[:space:]]+open|"
482
+ r"sensible-browser|x-www-browser|wslview|start[[:space:]]+(\"\"[[:space:]]+)?[\"']?https?://|"
483
+ r"explorer(\.exe)?[[:space:]]+[\"']?https?://|FileProtocolHandler|webbrowser\.|openExternal|"
484
+ r"[\"'`](open|xdg-open|wslview|start|explorer)[\"'`]")
485
+
486
+
487
+ def _aims(lines: List[str], port: int, host: str, dashboard_path: str) -> bool:
488
+ text = "\n".join(lines)
489
+ if "/fabric/v1/login" in text:
490
+ return True
491
+ # A loopback address is this service's only with its port or with none: another port is another local tool.
492
+ if any(m.group(1) is None or not port or int(m.group(1)) == port for m in _LOOPBACK.finditer(text)):
493
+ return True
494
+ if port and re.search(r"(?<!\d)%d(?!\d)" % port, text):
495
+ return True
496
+ if host and host in text:
497
+ return True
498
+ return bool(dashboard_path and dashboard_path != "/" and re.search(re.escape(dashboard_path) + r"(?![\w-])", text))
499
+
500
+
501
+ #: Text before an opener that makes it a message for a person: `echo "... open http://..."`, print, a log call.
502
+ _MESSAGE = re.compile(r"\b(?:echo|printf|print|puts|say)\b|\bconsole\.\w+\s*\(|\blog(?:ger)?\.\w+\s*\(|\blog\s*\(")
503
+ _IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
504
+ _FENCE = re.compile(r"\s*(?:```|~~~)")
505
+
506
+
507
+ def _names_dashboard(line: str) -> bool:
508
+ """An identifier built on "dashboard" (dashboard_url, openDashboard), never the word alone or the host's name."""
509
+ return any("dashboard" in word.lower() and word.lower() not in ("dashboard", "dashboards")
510
+ for word in _IDENTIFIER.findall(line.split("://", 1)[0]))
511
+
512
+
513
+ def _prose_code(lines: List[str]) -> List[str]:
514
+ """Each line of a prose file reduced to its code: whole inside a fenced block, else its inline code spans."""
515
+ out, fenced = [], False
516
+ for line in lines:
517
+ if _FENCE.match(line):
518
+ fenced = not fenced
519
+ out.append("")
520
+ elif fenced:
521
+ out.append(line)
522
+ else:
523
+ out.append(" ; ".join(re.findall(r"`([^`]+)`", line))) # each span its own command
524
+ return out
525
+
526
+
527
+ def opener_findings(path: str, text: str, port: int, origin: Optional[str], dashboard_path: Optional[str]) -> List[Tuple[int, str]]:
528
+ """The lines of one file (repository-relative `path`, its `text`) that open a browser at the service's pages, as
529
+ (line number, line); the matcher is described above. Never raises on what it reads."""
530
+ path = (path or "").replace("\\", "/")
531
+ if not isinstance(text, str) or _TEST_PATH.search(path):
532
+ return []
533
+ prose = path.lower().endswith(_PROSE_FILES)
534
+ calls = None
535
+ if path.lower().endswith(_JS_FILES):
536
+ names = [a or b for a, b in _OPEN_PACKAGE.findall(text)]
537
+ if names:
538
+ calls = re.compile(r"(?<![\w.$])(?:%s)\s*\(" % "|".join(re.escape(n) for n in names))
539
+ host = ""
540
+ if isinstance(origin, str) and "://" in origin:
541
+ host = origin.split("://", 1)[1].split("/", 1)[0].rsplit("@", 1)[-1]
542
+ if _LOOPBACK.fullmatch(host.rsplit(":", 1)[0]):
543
+ host = "" # a loopback origin is covered by the loopback hosts and the port
544
+ original = text.splitlines()
545
+ lines = _prose_code(original) if prose else original
546
+ out: List[Tuple[int, str]] = []
547
+ for index, line in enumerate(lines):
548
+ match = _OPENERS.search(line) or (calls.search(line) if calls is not None and not _OPEN_PACKAGE.search(line) else None)
549
+ if match is None or _MESSAGE.search(line[:match.start()]):
550
+ continue
551
+ if _HOST_ROUTE.search(line) or _GATE_LINK.search(line):
552
+ continue
553
+ if (prose and _NEGATION.search(original[index])) or _COMMENT.match(line):
554
+ continue
555
+ window = lines[max(0, index - _WINDOW):index + 1]
556
+ if any(_FALLBACK.search(w) for w in window):
557
+ continue
558
+ aim = [line] if prose else window
559
+ if _aims(aim, port, host, str(dashboard_path or "")) or _names_dashboard(line):
560
+ out.append((index + 1, original[index].strip()))
561
+ return out
562
+
563
+
564
+ def opens_in_host_verdict(repo: Path, port: int, origin: Optional[str], dashboard_path: Optional[str],
565
+ budget: float = OPENS_IN_HOST_BUDGET) -> Tuple[str, str]:
566
+ """`dashboard.opens-in-host` over one checkout: FAIL with the places a tracked text file opens a browser at the
567
+ service's pages, PASS when none does, NOT_RUN when the checkout or git cannot be read or the budget runs out."""
568
+ until = time.monotonic() + max(0.0, budget)
569
+ if not shutil.which("git"):
570
+ return "NOT_RUN", "git is not installed"
571
+
572
+ def left() -> float:
573
+ return until - time.monotonic()
574
+
575
+ if left() <= 0:
576
+ return "NOT_RUN", "the %.1f s budget ran out before the checkout was read" % budget
577
+ try:
578
+ top = subprocess.run(["git", "-C", str(repo), "rev-parse", "--show-toplevel"], capture_output=True, text=True,
579
+ timeout=max(0.05, left()))
580
+ if top.returncode != 0 or not top.stdout.strip():
581
+ return "NOT_RUN", "%s is not a Git checkout" % repo
582
+ root = Path(top.stdout.strip())
583
+ if left() <= 0:
584
+ return "NOT_RUN", "the %.1f s budget ran out before the checkout was read" % budget
585
+ listed = subprocess.run(["git", "-C", str(root), "grep", "-I", "-l", "-z", "-E", _COARSE], capture_output=True,
586
+ timeout=max(0.05, left()))
587
+ except subprocess.TimeoutExpired:
588
+ return "NOT_RUN", "the %.1f s budget ran out while git read %s" % (budget, repo)
589
+ except OSError as exc:
590
+ return "NOT_RUN", "git could not read %s: %s" % (repo, exc)
591
+ if listed.returncode not in (0, 1): # 1: nothing matched
592
+ return "NOT_RUN", "git grep could not read %s" % root
593
+ candidates = [p for p in listed.stdout.decode("utf-8", "surrogateescape").split("\0") if p]
594
+ if len(candidates) > _MAX_CANDIDATES:
595
+ return "NOT_RUN", "%d candidate files in %s, more than the %d this rule reads" % (len(candidates), root, _MAX_CANDIDATES)
596
+ found: List[str] = []
597
+ for name in candidates:
598
+ if left() <= 0:
599
+ return "NOT_RUN", "the %.1f s budget ran out after reading part of %s" % (budget, root)
600
+ try:
601
+ with open(root / name, "rb") as handle:
602
+ text = handle.read(_MAX_FILE_BYTES).decode("utf-8", "replace")
603
+ except OSError:
604
+ continue
605
+ found += ["%s:%d %s" % (name, line, snippet[:100]) for line, snippet in opener_findings(name, text, port, origin, dashboard_path)]
606
+ if found:
607
+ return "FAIL", ("%s. Open the service's pages through Fabric Dashboards: the open_link of the fabric-dashboards MCP "
608
+ "link tool, opened with fallback=never; a browser only on a confirmed not_installed host "
609
+ "(operator rule of 2026-10-24)" % "; ".join(found[:5] + (["and %d more" % (len(found) - 5)] if len(found) > 5 else [])))
610
+ return "PASS", ("%d tracked text file(s) in %s name a possible opener; none opens a browser at the service's pages: "
611
+ "they open through Fabric Dashboards" % (len(candidates), root))
612
+
613
+
614
+ def _git_out(cwd: Path, *args: str) -> Optional[str]:
615
+ try:
616
+ done = subprocess.run(["git", "-C", str(cwd)] + list(args), capture_output=True, text=True, timeout=2.0)
617
+ except (OSError, subprocess.SubprocessError):
618
+ return None
619
+ return done.stdout.strip() if done.returncode == 0 and done.stdout.strip() else None
620
+
621
+
622
+ def service_checkout(descriptor: Any, repo: Optional[str]) -> Tuple[Optional[Path], str]:
623
+ """The service's checkout: `repo` (the probe's --repo) when given, else the checkout holding one of the
624
+ descriptor's paths (its commands, its fabricManifest, its launchd job's program and working directory) whose origin
625
+ is the descriptor's `source.repository`. (None, why) when there is none."""
626
+ if repo:
627
+ top = _git_out(Path(repo).expanduser(), "rev-parse", "--show-toplevel")
628
+ return (Path(top), "--repo") if top else (None, "--repo %s is not a Git checkout" % repo)
629
+ d = descriptor if isinstance(descriptor, dict) else {}
630
+ own = d.get("source")
631
+ source = own.get("repository") if isinstance(own, dict) else None
632
+ if not isinstance(source, str) or not source:
633
+ return None, "the descriptor names no source.repository to find the service's checkout by; pass --repo"
634
+ places: List[str] = []
635
+ commands = d.get("commands")
636
+ for argv in (commands.values() if isinstance(commands, dict) else []):
637
+ places += [a for a in argv if isinstance(a, str)] if isinstance(argv, list) else []
638
+ if isinstance(d.get("fabricManifest"), str):
639
+ places.append(d["fabricManifest"])
640
+ life = d.get("lifecycle") if isinstance(d.get("lifecycle"), dict) else {}
641
+ if isinstance(life.get("plist"), str):
642
+ try:
643
+ import plistlib
644
+ plist = plistlib.loads(Path(life["plist"]).expanduser().read_bytes())
645
+ places += [a for a in (plist.get("ProgramArguments") or []) if isinstance(a, str)]
646
+ places += [plist["WorkingDirectory"]] if isinstance(plist.get("WorkingDirectory"), str) else []
647
+ except (OSError, ValueError, AttributeError, TypeError, plistlib.InvalidFileException):
648
+ pass
649
+ seen = set()
650
+ for place in places:
651
+ if not (place.startswith(("/", "~")) or re.match(r"[A-Za-z]:[\\/]", place)):
652
+ continue
653
+ start = Path(os.path.expanduser(place))
654
+ while not start.is_dir() and start != start.parent:
655
+ start = start.parent
656
+ top = _git_out(start, "rev-parse", "--show-toplevel")
657
+ if not top or top in seen:
658
+ continue
659
+ seen.add(top)
660
+ if same_repository(_git_out(Path(top), "remote", "get-url", "origin") or "", source):
661
+ return Path(top), "the checkout of %s that holds %s" % (source, place)
662
+ return None, "no checkout of %s holds the descriptor's commands, manifest or launchd job; pass --repo" % source