@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +18 -6
  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 +42 -33
  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 +104 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  106. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  107. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -57,74 +57,16 @@ import fabric_interop as fi # noqa: E402
57
57
  Result = Dict[str, str]
58
58
 
59
59
 
60
-
61
- def plist_problems(plist: dict, label: object, token: object = None) -> list:
62
- """What makes a service's launchd job unfit to stay up: identity, restart, secrets."""
63
- problems = []
64
- if plist.get("Label") != label:
65
- problems.append("Label %r" % plist.get("Label"))
66
- if plist.get("RunAtLoad") is not True:
67
- problems.append("RunAtLoad is not true")
68
- if plist.get("KeepAlive") is not True:
69
- problems.append("KeepAlive is %r, not true" % plist.get("KeepAlive"))
70
- for key, value in (plist.get("EnvironmentVariables") or {}).items():
71
- if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)$", key) and not key.endswith("_FILE"):
72
- problems.append("secret-like variable %s in the plist" % key)
73
- if token and token in str(value):
74
- problems.append("the service token itself is in the plist")
75
- return problems
76
-
77
-
78
- def marker_problem(plist: dict) -> Optional[str]:
79
- """What is wrong with the job's supervisor marker (DEC-0057; FAA-05), read the way the copy it starts reads it.
80
-
81
- Without ``FABRIC_SERVICE_SUPERVISOR=launchd`` (or ``--fabric-supervisor launchd``) a copy that finds the
82
- lock held exits 75 at once, and KeepAlive starts it again every ThrottleInterval."""
83
- env = plist.get("EnvironmentVariables")
84
- env = {str(k): str(v) for k, v in env.items()} if isinstance(env, dict) else {}
85
- argv = plist.get("ProgramArguments")
86
- argv = [str(a) for a in argv] if isinstance(argv, list) else []
87
- marker = fs.supervisor_marker(env, argv)
88
- if marker["kind"] == "supervised" and marker["manager"] == "launchd":
89
- return None
90
- fix = ("a copy that finds the lock held exits 75 at once and KeepAlive respawns it every ThrottleInterval; "
91
- "regenerate the plist with launchd_plist / launchdPlist and reinstall")
92
- if marker["kind"] == "conflict":
93
- return "%s: %s" % (fs.marker_conflict_message(marker), fix)
94
- if marker["kind"] == "supervised":
95
- return "the marker names %s, not launchd: %s" % (marker["manager"], fix)
96
- if marker.get("unreadable") is not None:
97
- return "the marker %r names no supervisor: %s" % (marker["unreadable"], fix)
98
- return "no %s=launchd: %s" % (fs.SUPERVISOR_ENV, fix)
99
-
100
-
101
- def priority_problems(plist: dict) -> list:
102
- """A service that answers hosts and agents must not be scheduled as background work."""
103
- out = []
104
- if plist.get("ProcessType", "Standard") in ("Background", "Adaptive"):
105
- out.append("ProcessType is %s" % plist.get("ProcessType"))
106
- if isinstance(plist.get("Nice"), int) and plist["Nice"] > 0:
107
- out.append("Nice is %d" % plist["Nice"])
108
- if plist.get("LowPriorityIO") is True or plist.get("LowPriorityBackgroundIO") is True:
109
- out.append("low-priority I/O")
110
- return out
111
-
112
-
113
- # A platform router (Heroku, Fly, Render, a CDN) routes by Host, so a request naming a foreign
114
- # Host never reaches an online service: the router itself answers, typically 404 or 421. That is
115
- # still a refusal — the service disclosed nothing — so for a remote placement the host check
116
- # accepts it, as long as the body is not the well-known document. Every other guard request names
117
- # the right Host, reaches the service, and must be the service's own 403.
118
- PLATFORM_HOST_REFUSALS = (400, 404, 421)
119
-
120
-
121
- def guard_verdict(rule: str, remote: bool, status: int, body: bytes) -> Tuple[str, str]:
122
- if status == 403:
123
- return "PASS", "HTTP 403"
124
- disclosed = b"fabric-service/" in (body or b"")
125
- if rule == "network.host-check" and remote and status in PLATFORM_HOST_REFUSALS and not disclosed:
126
- return "PASS", "HTTP %d from the platform router: a foreign Host never reaches the service" % status
127
- return "FAIL", "HTTP %d%s" % (status, ", and the body is the well-known document" if disclosed else "")
60
+ from check_service_rules import PLATFORM_HOST_REFUSALS # noqa: E402,F401 - moved (FAA-65)
61
+ from check_service_rules import plist_problems # noqa: E402,F401 - moved (FAA-65)
62
+ from check_service_rules import marker_problem # noqa: E402,F401 - moved (FAA-65)
63
+ from check_service_rules import priority_problems # noqa: E402,F401 - moved (FAA-65)
64
+ from check_service_rules import guard_verdict # noqa: E402,F401 - moved (FAA-65)
65
+ from check_service_rules import board_responder_verdict # noqa: E402,F401 - moved (FAA-65)
66
+ from check_service_rules import session_lifetime_problem # noqa: E402,F401 - moved (FAA-65)
67
+ from check_service_rules import _semver # noqa: E402,F401 - moved (FAA-65)
68
+ from check_service_rules import usage_problems # noqa: E402,F401 - moved (FAA-65)
69
+ from check_service_rules import same_repository # noqa: E402,F401 - moved (FAA-65)
128
70
 
129
71
 
130
72
  class _PinnedHTTPS(http.client.HTTPSConnection):
@@ -376,7 +318,7 @@ class Probe:
376
318
  self.events = events
377
319
  bad = []
378
320
  for e in events:
379
- 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", ""))):
380
322
  bad.append(str(e.get("id")))
381
323
  elif " " not in str(e.get("text", "")).strip():
382
324
  bad.append("%s (text is not a sentence)" % e.get("id"))
@@ -839,121 +781,6 @@ class Probe:
839
781
  return self.results
840
782
 
841
783
 
842
- def board_responder_verdict(wk: Any, kit: Optional[str] = None) -> Tuple[str, str]:
843
- """board.responder: NOT_RUN when the well-known document declares no fabric-project-comms revision; FAIL when it
844
- declares one this kit does not read or the kit's fixture conversation finds a problem; PASS otherwise. `kit` is the
845
- service's own copy (a .py or .mjs path); by default the kit beside this probe."""
846
- protocols = wk.get("protocols") if isinstance(wk, dict) else None
847
- declared = [p for p in protocols if isinstance(p, str) and p.startswith("fabric-project-comms/")] if isinstance(protocols, list) else []
848
- if not declared:
849
- return "NOT_RUN", "the service declares no board participation (no fabric-project-comms revision in protocols)"
850
- accepted = fs.accepted_revisions("fabric-project-comms")
851
- unread = [p for p in declared if "error" in fs.negotiate_revision(p, accepted)]
852
- if unread:
853
- return "FAIL", "declares %s, which this kit does not read (it reads %s)" % (", ".join(unread), ", ".join(accepted))
854
- try:
855
- if kit and kit.endswith(".mjs"):
856
- if not shutil.which("node"):
857
- return "NOT_RUN", "node is not installed to run %s's fixture conversation" % kit
858
- url = Path(kit).resolve().as_uri()
859
- out = subprocess.run(["node", "--input-type=module", "-e",
860
- "import(process.env.FABRIC_BOARD_KIT).then(async (m) => console.log(JSON.stringify(await m.selfCheck())))"],
861
- env=dict(os.environ, FABRIC_BOARD_KIT=url), capture_output=True, text=True, timeout=60)
862
- problems = json.loads(out.stdout.strip().splitlines()[-1]) if out.returncode == 0 and out.stdout.strip() else ["the Node kit did not run: %s" % out.stderr.strip()[:200]]
863
- else:
864
- import importlib.util
865
- path = Path(kit) if kit else Path(__file__).resolve().parent / "fabric_board.py"
866
- spec = importlib.util.spec_from_file_location("fabric_board_under_test", str(path))
867
- module = importlib.util.module_from_spec(spec)
868
- sys.path.insert(0, str(path.resolve().parent))
869
- spec.loader.exec_module(module) # type: ignore[union-attr]
870
- problems = module.self_check()
871
- except Exception as exc: # noqa: BLE001 - a kit that cannot run its conversation fails the rule
872
- return "FAIL", "the kit's fixture conversation did not run: %s" % exc.__class__.__name__
873
- if problems:
874
- return "FAIL", "; ".join(problems)
875
- return "PASS", "declares %s; the kit%s answers the fixture conversation (whole, fenced, no epoch)" % (
876
- ", ".join(declared), " at %s" % kit if kit else " beside this probe")
877
-
878
-
879
- def session_lifetime_problem(set_cookie: str, now: Optional[float] = None) -> Optional[str]:
880
- """DEC-0039: a session cookie set at sign-in lives at most 7 days. Max-Age wins over Expires (RFC 6265
881
- §5.3); a cookie with neither ends with the browser session. None when it is within the bound."""
882
- attrs = {}
883
- for part in set_cookie.split(";")[1:]:
884
- name, _, value = part.strip().partition("=")
885
- attrs[name.strip().lower()] = value.strip()
886
- bound = fs.OPERATOR_SESSION_MAX_SECONDS
887
- if "max-age" in attrs:
888
- if not re.fullmatch(r"-?\d+", attrs["max-age"]):
889
- return "the session cookie's Max-Age %r is not a number" % attrs["max-age"]
890
- if int(attrs["max-age"]) > bound:
891
- return "the session cookie's Max-Age %s outlives 7 days (%d s, DEC-0039)" % (attrs["max-age"], bound)
892
- return None
893
- if "expires" in attrs:
894
- try:
895
- at = email.utils.parsedate_to_datetime(attrs["expires"]).timestamp()
896
- except (TypeError, ValueError, IndexError, OverflowError):
897
- return "the session cookie's Expires %r is not a date" % attrs["expires"]
898
- if at - (time.time() if now is None else now) > bound:
899
- return "the session cookie's Expires %s outlives 7 days (DEC-0039)" % attrs["expires"]
900
- return None
901
-
902
-
903
- def _semver(text: str) -> Tuple[int, int, int]:
904
- m = re.match(r"^(\d+)\.(\d+)\.(\d+)", text or "")
905
- return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0, 0, 0)
906
-
907
-
908
- def usage_problems(report: Any, service_id: str, instance: str) -> List[str]:
909
- """What makes this not a DEC-0021 usage report of the service: shape, identity and the sums of
910
- FAC-SEM-025. An unknown cost must be null; a day's totals are the sums of its models."""
911
- if not isinstance(report, dict):
912
- return ["not an object"]
913
- out: List[str] = []
914
- if report.get("protocol") != fs.PROTOCOL:
915
- out.append("protocol is %r" % report.get("protocol"))
916
- if report.get("service") != {"id": service_id, "instance": instance}:
917
- out.append("reports for %r, not %s.%s" % (report.get("service"), service_id, instance))
918
- if report.get("currency") != "USD":
919
- out.append("currency is not USD")
920
- days = report.get("days")
921
- if not isinstance(days, list) or len(days) > fs.USAGE_DAYS:
922
- return out + ["days is not a list of at most 31"]
923
- # The arithmetic of FAC-SEM-025 is the kit's `usage_findings`, the contract's `usageRules` held to its vectors: one
924
- # implementation, so the probe never disagrees with the kit and never throws on a count that is not a number.
925
- for i, day in enumerate(days):
926
- if not isinstance(day, dict):
927
- out.append("days[%d]: not an object" % i)
928
- continue
929
- if not isinstance(day.get("byModel"), list):
930
- out.append("days[%d]: byModel missing" % i)
931
- continue
932
- for row, at in [(day, "days[%d]" % i)] + [(m, "days[%d].byModel[%d]" % (i, j)) for j, m in enumerate(day["byModel"]) if isinstance(m, dict)]:
933
- cost = row.get("costUsd")
934
- if cost is not None and (isinstance(cost, bool) or not isinstance(cost, (int, float)) or cost < 0):
935
- out.append("%s: costUsd is not a non-negative number" % at)
936
- for j, m in enumerate(day["byModel"]):
937
- # DEC-0046, DEC-0061: a row's route is a login in its vendor's runner, an issued key, or the platform's
938
- # own metered models (a listing in the cloud).
939
- if isinstance(m, dict) and "route" in m and m["route"] not in fs.USAGE_ROUTES:
940
- out.append("days[%d].byModel[%d]: route %r is not subscription, key or platform-metered" % (i, j, m["route"]))
941
- for finding in fs.usage_findings(report):
942
- out.append("%s: %s" % (finding["instancePath"] or "/", finding["message"]))
943
- return out
944
-
945
-
946
- def same_repository(a: str, b: str) -> bool:
947
- """git@github.com:o/r.git, https://github.com/o/r and github.com/o/r are one repository."""
948
- def norm(u: str) -> str:
949
- u = u.strip().lower()
950
- u = re.sub(r"^git@([^:]+):", r"\1/", u)
951
- u = re.sub(r"^[a-z+]+://", "", u)
952
- u = re.sub(r"^[^@/]+@", "", u)
953
- return re.sub(r"\.git$", "", u).rstrip("/")
954
- return bool(a and b) and norm(a) == norm(b)
955
-
956
-
957
784
  def locate(target: Optional[str], descriptor: Optional[str], services_dir: Path) -> Path:
958
785
  if descriptor:
959
786
  return Path(descriptor).expanduser()
@@ -0,0 +1,204 @@
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.
4
+ Standard library only, Python 3.9+."""
5
+ from __future__ import annotations
6
+
7
+ import email.utils
8
+ import json
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional, Tuple
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
19
+ import fabric_service as fs # noqa: E402 - beside this file
20
+
21
+
22
+ def plist_problems(plist: dict, label: object, token: object = None) -> list:
23
+ """What makes a service's launchd job unfit to stay up: identity, restart, secrets."""
24
+ problems = []
25
+ if plist.get("Label") != label:
26
+ problems.append("Label %r" % plist.get("Label"))
27
+ if plist.get("RunAtLoad") is not True:
28
+ problems.append("RunAtLoad is not true")
29
+ if plist.get("KeepAlive") is not True:
30
+ problems.append("KeepAlive is %r, not true" % plist.get("KeepAlive"))
31
+ for key, value in (plist.get("EnvironmentVariables") or {}).items():
32
+ if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)\Z", key) and not key.endswith("_FILE"):
33
+ problems.append("secret-like variable %s in the plist" % key)
34
+ if token and token in str(value):
35
+ problems.append("the service token itself is in the plist")
36
+ return problems
37
+
38
+
39
+ def marker_problem(plist: dict) -> Optional[str]:
40
+ """What is wrong with the job's supervisor marker (DEC-0057; FAA-05), read the way the copy it starts reads it.
41
+
42
+ Without ``FABRIC_SERVICE_SUPERVISOR=launchd`` (or ``--fabric-supervisor launchd``) a copy that finds the
43
+ lock held exits 75 at once, and KeepAlive starts it again every ThrottleInterval."""
44
+ env = plist.get("EnvironmentVariables")
45
+ env = {str(k): str(v) for k, v in env.items()} if isinstance(env, dict) else {}
46
+ argv = plist.get("ProgramArguments")
47
+ argv = [str(a) for a in argv] if isinstance(argv, list) else []
48
+ marker = fs.supervisor_marker(env, argv)
49
+ if marker["kind"] == "supervised" and marker["manager"] == "launchd":
50
+ return None
51
+ fix = ("a copy that finds the lock held exits 75 at once and KeepAlive respawns it every ThrottleInterval; "
52
+ "regenerate the plist with launchd_plist / launchdPlist and reinstall")
53
+ if marker["kind"] == "conflict":
54
+ return "%s: %s" % (fs.marker_conflict_message(marker), fix)
55
+ if marker["kind"] == "supervised":
56
+ return "the marker names %s, not launchd: %s" % (marker["manager"], fix)
57
+ if marker.get("unreadable") is not None:
58
+ return "the marker %r names no supervisor: %s" % (marker["unreadable"], fix)
59
+ return "no %s=launchd: %s" % (fs.SUPERVISOR_ENV, fix)
60
+
61
+
62
+ def priority_problems(plist: dict) -> list:
63
+ """A service that answers hosts and agents must not be scheduled as background work."""
64
+ out = []
65
+ if plist.get("ProcessType", "Standard") in ("Background", "Adaptive"):
66
+ out.append("ProcessType is %s" % plist.get("ProcessType"))
67
+ if isinstance(plist.get("Nice"), int) and plist["Nice"] > 0:
68
+ out.append("Nice is %d" % plist["Nice"])
69
+ if plist.get("LowPriorityIO") is True or plist.get("LowPriorityBackgroundIO") is True:
70
+ out.append("low-priority I/O")
71
+ return out
72
+
73
+
74
+ # A platform router (Heroku, Fly, Render, a CDN) routes by Host, so a request naming a foreign
75
+ # Host never reaches an online service: the router itself answers, typically 404 or 421. That is
76
+ # still a refusal — the service disclosed nothing — so for a remote placement the host check
77
+ # accepts it, as long as the body is not the well-known document. Every other guard request names
78
+ # the right Host, reaches the service, and must be the service's own 403.
79
+ PLATFORM_HOST_REFUSALS = (400, 404, 421)
80
+
81
+
82
+ def guard_verdict(rule: str, remote: bool, status: int, body: bytes) -> Tuple[str, str]:
83
+ if status == 403:
84
+ return "PASS", "HTTP 403"
85
+ disclosed = b"fabric-service/" in (body or b"")
86
+ if rule == "network.host-check" and remote and status in PLATFORM_HOST_REFUSALS and not disclosed:
87
+ return "PASS", "HTTP %d from the platform router: a foreign Host never reaches the service" % status
88
+ return "FAIL", "HTTP %d%s" % (status, ", and the body is the well-known document" if disclosed else "")
89
+
90
+
91
+ def board_responder_verdict(wk: Any, kit: Optional[str] = None) -> Tuple[str, str]:
92
+ """board.responder: NOT_RUN when the well-known document declares no fabric-project-comms revision; FAIL when it
93
+ declares one this kit does not read or the kit's fixture conversation finds a problem; PASS otherwise. `kit` is the
94
+ service's own copy (a .py or .mjs path); by default the kit beside this probe."""
95
+ protocols = wk.get("protocols") if isinstance(wk, dict) else None
96
+ declared = [p for p in protocols if isinstance(p, str) and p.startswith("fabric-project-comms/")] if isinstance(protocols, list) else []
97
+ if not declared:
98
+ return "NOT_RUN", "the service declares no board participation (no fabric-project-comms revision in protocols)"
99
+ accepted = fs.accepted_revisions("fabric-project-comms")
100
+ unread = [p for p in declared if "error" in fs.negotiate_revision(p, accepted)]
101
+ if unread:
102
+ return "FAIL", "declares %s, which this kit does not read (it reads %s)" % (", ".join(unread), ", ".join(accepted))
103
+ try:
104
+ if kit and kit.endswith(".mjs"):
105
+ if not shutil.which("node"):
106
+ return "NOT_RUN", "node is not installed to run %s's fixture conversation" % kit
107
+ url = Path(kit).resolve().as_uri()
108
+ out = subprocess.run(["node", "--input-type=module", "-e",
109
+ "import(process.env.FABRIC_BOARD_KIT).then(async (m) => console.log(JSON.stringify(await m.selfCheck())))"],
110
+ env=dict(os.environ, FABRIC_BOARD_KIT=url), capture_output=True, text=True, timeout=60)
111
+ problems = json.loads(out.stdout.strip().splitlines()[-1]) if out.returncode == 0 and out.stdout.strip() else ["the Node kit did not run: %s" % out.stderr.strip()[:200]]
112
+ else:
113
+ # A clean interpreter: the kit is several modules (FAA-65), and modules this probe already imported from
114
+ # beside it would stand in for the copy under test.
115
+ path = Path(kit) if kit else Path(__file__).resolve().parent / "fabric_board.py"
116
+ code = ("import json, sys; sys.path.insert(0, sys.argv[1]); import %s as board; "
117
+ "print(json.dumps(board.self_check()))" % path.stem)
118
+ out = subprocess.run([sys.executable, "-E", "-s", "-c", code, str(path.resolve().parent)], capture_output=True,
119
+ text=True, timeout=60, cwd=str(path.resolve().parent))
120
+ problems = json.loads(out.stdout.strip().splitlines()[-1]) if out.returncode == 0 and out.stdout.strip() else ["the kit did not run: %s" % out.stderr.strip()[-200:]]
121
+ except Exception as exc: # noqa: BLE001 - a kit that cannot run its conversation fails the rule
122
+ return "FAIL", "the kit's fixture conversation did not run: %s" % exc.__class__.__name__
123
+ if problems:
124
+ return "FAIL", "; ".join(problems)
125
+ return "PASS", "declares %s; the kit%s answers the fixture conversation (whole, fenced, no epoch)" % (
126
+ ", ".join(declared), " at %s" % kit if kit else " beside this probe")
127
+
128
+
129
+ def session_lifetime_problem(set_cookie: str, now: Optional[float] = None) -> Optional[str]:
130
+ """DEC-0039: a session cookie set at sign-in lives at most 7 days. Max-Age wins over Expires (RFC 6265
131
+ §5.3); a cookie with neither ends with the browser session. None when it is within the bound."""
132
+ attrs = {}
133
+ for part in set_cookie.split(";")[1:]:
134
+ name, _, value = part.strip().partition("=")
135
+ attrs[name.strip().lower()] = value.strip()
136
+ bound = fs.OPERATOR_SESSION_MAX_SECONDS
137
+ if "max-age" in attrs:
138
+ if not re.fullmatch(r"-?\d+", attrs["max-age"]):
139
+ return "the session cookie's Max-Age %r is not a number" % attrs["max-age"]
140
+ if int(attrs["max-age"]) > bound:
141
+ return "the session cookie's Max-Age %s outlives 7 days (%d s, DEC-0039)" % (attrs["max-age"], bound)
142
+ return None
143
+ if "expires" in attrs:
144
+ try:
145
+ at = email.utils.parsedate_to_datetime(attrs["expires"]).timestamp()
146
+ except (TypeError, ValueError, IndexError, OverflowError):
147
+ return "the session cookie's Expires %r is not a date" % attrs["expires"]
148
+ if at - (time.time() if now is None else now) > bound:
149
+ return "the session cookie's Expires %s outlives 7 days (DEC-0039)" % attrs["expires"]
150
+ return None
151
+
152
+
153
+ def _semver(text: str) -> Tuple[int, int, int]:
154
+ m = re.match(r"^(\d+)\.(\d+)\.(\d+)", text or "")
155
+ return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0, 0, 0)
156
+
157
+
158
+ def usage_problems(report: Any, service_id: str, instance: str) -> List[str]:
159
+ """What makes this not a DEC-0021 usage report of the service: shape, identity and the sums of
160
+ FAC-SEM-025. An unknown cost must be null; a day's totals are the sums of its models."""
161
+ if not isinstance(report, dict):
162
+ return ["not an object"]
163
+ out: List[str] = []
164
+ if report.get("protocol") != fs.PROTOCOL:
165
+ out.append("protocol is %r" % report.get("protocol"))
166
+ if report.get("service") != {"id": service_id, "instance": instance}:
167
+ out.append("reports for %r, not %s.%s" % (report.get("service"), service_id, instance))
168
+ if report.get("currency") != "USD":
169
+ out.append("currency is not USD")
170
+ days = report.get("days")
171
+ if not isinstance(days, list) or len(days) > fs.USAGE_DAYS:
172
+ return out + ["days is not a list of at most 31"]
173
+ # The arithmetic of FAC-SEM-025 is the kit's `usage_findings`, the contract's `usageRules` held to its vectors: one
174
+ # implementation, so the probe never disagrees with the kit and never throws on a count that is not a number.
175
+ for i, day in enumerate(days):
176
+ if not isinstance(day, dict):
177
+ out.append("days[%d]: not an object" % i)
178
+ continue
179
+ if not isinstance(day.get("byModel"), list):
180
+ out.append("days[%d]: byModel missing" % i)
181
+ continue
182
+ for row, at in [(day, "days[%d]" % i)] + [(m, "days[%d].byModel[%d]" % (i, j)) for j, m in enumerate(day["byModel"]) if isinstance(m, dict)]:
183
+ cost = row.get("costUsd")
184
+ if cost is not None and (isinstance(cost, bool) or not isinstance(cost, (int, float)) or cost < 0):
185
+ out.append("%s: costUsd is not a non-negative number" % at)
186
+ for j, m in enumerate(day["byModel"]):
187
+ # DEC-0046, DEC-0061: a row's route is a login in its vendor's runner, an issued key, or the platform's
188
+ # own metered models (a listing in the cloud).
189
+ if isinstance(m, dict) and "route" in m and m["route"] not in fs.USAGE_ROUTES:
190
+ out.append("days[%d].byModel[%d]: route %r is not subscription, key or platform-metered" % (i, j, m["route"]))
191
+ for finding in fs.usage_findings(report):
192
+ out.append("%s: %s" % (finding["instancePath"] or "/", finding["message"]))
193
+ return out
194
+
195
+
196
+ def same_repository(a: str, b: str) -> bool:
197
+ """git@github.com:o/r.git, https://github.com/o/r and github.com/o/r are one repository."""
198
+ def norm(u: str) -> str:
199
+ u = u.strip().lower()
200
+ u = re.sub(r"^git@([^:]+):", r"\1/", u)
201
+ u = re.sub(r"^[a-z+]+://", "", u)
202
+ u = re.sub(r"^[^@/]+@", "", u)
203
+ return re.sub(r"\.git$", "", u).rstrip("/")
204
+ return bool(a and b) and norm(a) == norm(b)
@@ -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