@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.
- package/CHANGELOG.md +16 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +29 -11
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +58 -5
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +11 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +8 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +50 -12
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +5 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +5 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +3 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +3 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +58 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +13 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +13 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +60 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +159 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +8 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +4 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +14 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +40 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +8 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +67 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +371 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +170 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +275 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +3 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +151 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +38 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +31 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +6 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +68 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +31 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +247 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-page-links.mjs +144 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +22 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +30 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +209 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +340 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +170 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +36 -12
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +36 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +7 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +242 -11
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +70 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +324 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_page_links.py +559 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +21 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +9 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +35 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +6 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +413 -25
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -11
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +58 -5
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +11 -3
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +8 -6
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +87 -10
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +12 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
- 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,
|
|
3
|
-
whether two repository URLs name one repository
|
|
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
|