@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.
- package/CHANGELOG.md +203 -0
- package/README.md +18 -6
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -57,74 +57,16 @@ import fabric_interop as fi # noqa: E402
|
|
|
57
57
|
Result = Dict[str, str]
|
|
58
58
|
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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_]*)
|
|
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()
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py
ADDED
|
@@ -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)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py
CHANGED
|
@@ -23,6 +23,7 @@ from pathlib import Path
|
|
|
23
23
|
import re
|
|
24
24
|
import subprocess
|
|
25
25
|
import sys
|
|
26
|
+
import time
|
|
26
27
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
27
28
|
|
|
28
29
|
KIT_DIR = Path(__file__).resolve().parent.parent / "assets" / "dashboard-kit"
|
|
@@ -34,6 +35,9 @@ JS_BEGIN, JS_END = "/* fabric-dashboard-kit:theme-ids:begin */", "/* fabric-dash
|
|
|
34
35
|
TOKEN_ORDER = ("bg", "panel", "panel-2", "ink", "muted", "border", "border-strong", "edge", "accent",
|
|
35
36
|
"accent-weak", "accent-ink", "ok", "ok-weak", "warn", "warn-weak", "danger", "danger-weak",
|
|
36
37
|
"info", "info-weak")
|
|
38
|
+
#: The SessionStart notice's total for every git call and the tree walk together: under the hook's 5 s (FAA-69).
|
|
39
|
+
NOTICE_BUDGET_S = 3.5
|
|
40
|
+
|
|
37
41
|
SKIP_DIRS = {".git", "node_modules", "dist", "build", ".venv", "venv", "__pycache__", ".next", "out"}
|
|
38
42
|
EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
|
|
39
43
|
|
|
@@ -187,27 +191,44 @@ def is_kit_source(path: Path) -> bool:
|
|
|
187
191
|
return False
|
|
188
192
|
|
|
189
193
|
|
|
190
|
-
|
|
194
|
+
class OutOfTime(Exception):
|
|
195
|
+
"""A shared deadline (`until`, a time.monotonic() value) passed before the answer was known."""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def git(repo: Path, *args: str, timeout: float = 2.0, until: Optional[float] = None) -> Optional[str]:
|
|
199
|
+
"""git's stdout, or None. With `until`, the call gets at most what is left of that deadline, and a call the
|
|
200
|
+
deadline cuts short raises OutOfTime instead of answering None."""
|
|
201
|
+
if until is not None:
|
|
202
|
+
left = until - time.monotonic()
|
|
203
|
+
if left <= 0:
|
|
204
|
+
raise OutOfTime()
|
|
205
|
+
timeout = min(timeout, left)
|
|
191
206
|
try:
|
|
192
207
|
done = subprocess.run(["git", "-C", str(repo)] + list(args), capture_output=True, text=True, timeout=timeout)
|
|
208
|
+
except subprocess.TimeoutExpired:
|
|
209
|
+
if until is not None and time.monotonic() >= until - 0.05:
|
|
210
|
+
raise OutOfTime()
|
|
211
|
+
return None
|
|
193
212
|
except (OSError, subprocess.SubprocessError):
|
|
194
213
|
return None
|
|
195
214
|
return done.stdout if done.returncode == 0 else None
|
|
196
215
|
|
|
197
216
|
|
|
198
|
-
def repo_root(start: Path) -> Optional[Path]:
|
|
199
|
-
out = git(start, "rev-parse", "--show-toplevel")
|
|
217
|
+
def repo_root(start: Path, until: Optional[float] = None) -> Optional[Path]:
|
|
218
|
+
out = git(start, "rev-parse", "--show-toplevel", until=until)
|
|
200
219
|
return Path(out.strip()) if out and out.strip() else None
|
|
201
220
|
|
|
202
221
|
|
|
203
|
-
def find_copies(repo: Path) -> List[Path]:
|
|
222
|
+
def find_copies(repo: Path, until: Optional[float] = None) -> List[Path]:
|
|
204
223
|
listed = git(repo, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--",
|
|
205
|
-
"*fabric-dashboard.css", "*fabric-dashboard.js", "*fabric-button.js")
|
|
224
|
+
"*fabric-dashboard.css", "*fabric-dashboard.js", "*fabric-button.js", until=until)
|
|
206
225
|
if listed is not None:
|
|
207
226
|
paths = [repo / p for p in listed.split("\0") if p]
|
|
208
227
|
else:
|
|
209
228
|
paths = []
|
|
210
229
|
for top, dirs, files in os.walk(repo):
|
|
230
|
+
if until is not None and time.monotonic() >= until:
|
|
231
|
+
raise OutOfTime()
|
|
211
232
|
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
|
212
233
|
if len(Path(top).relative_to(repo).parts) > 6:
|
|
213
234
|
dirs[:] = []
|
|
@@ -256,9 +277,9 @@ def same_repository(a: str, b: str) -> bool:
|
|
|
256
277
|
return bool(a and b) and norm(a) == norm(b)
|
|
257
278
|
|
|
258
279
|
|
|
259
|
-
def services_of(repo: Path, directory: Optional[Path] = None) -> List[str]:
|
|
280
|
+
def services_of(repo: Path, directory: Optional[Path] = None, until: Optional[float] = None) -> List[str]:
|
|
260
281
|
"""The services whose descriptor names this repository as its source (`source.repository`)."""
|
|
261
|
-
origin = (git(repo, "config", "--get", "remote.origin.url") or "").strip()
|
|
282
|
+
origin = (git(repo, "config", "--get", "remote.origin.url", until=until) or "").strip()
|
|
262
283
|
if not origin:
|
|
263
284
|
return []
|
|
264
285
|
found = []
|
|
@@ -277,19 +298,24 @@ def services_of(repo: Path, directory: Optional[Path] = None) -> List[str]:
|
|
|
277
298
|
return found
|
|
278
299
|
|
|
279
300
|
|
|
280
|
-
def declares_dashboard(repo: Path) -> bool:
|
|
301
|
+
def declares_dashboard(repo: Path, until: Optional[float] = None) -> bool:
|
|
281
302
|
"""Whether the repository's code declares a dashboard surface (a quick git grep, no network)."""
|
|
282
303
|
out = git(repo, "grep", "--untracked", "-l", "-I", "-E", r"""["']?dashboard["']?[[:space:]]*[:=][[:space:]]*\{?[[:space:]]*["']?path""", "--",
|
|
283
|
-
"*.py", "*.mjs", "*.js", "*.ts", "*.json", timeout=2.0)
|
|
304
|
+
"*.py", "*.mjs", "*.js", "*.ts", "*.json", timeout=2.0, until=until)
|
|
284
305
|
return bool(out and out.strip())
|
|
285
306
|
|
|
286
307
|
|
|
287
|
-
def status(repo: Path, manifest: Optional[Dict[str, Any]] = None, directory: Optional[Path] = None
|
|
308
|
+
def status(repo: Path, manifest: Optional[Dict[str, Any]] = None, directory: Optional[Path] = None,
|
|
309
|
+
services: Optional[List[str]] = None, until: Optional[float] = None) -> Dict[str, Any]:
|
|
310
|
+
"""The kit copies in `repo`, the services it owns and whether it has a dashboard. `services`, when the caller has
|
|
311
|
+
them already; `until`, a shared deadline (OutOfTime when it passes)."""
|
|
288
312
|
manifest = manifest or load_manifest()
|
|
289
|
-
|
|
290
|
-
|
|
313
|
+
if services is None:
|
|
314
|
+
services = services_of(repo, directory, until=until)
|
|
315
|
+
copies = [copy_state(p, manifest) for p in find_copies(repo, until=until)]
|
|
291
316
|
return {"repository": str(repo), "kit": "%s@%s" % (KIT_NAME, manifest["version"]), "copies": copies,
|
|
292
|
-
"services": services,
|
|
317
|
+
"services": services,
|
|
318
|
+
"dashboard": bool(copies) or (bool(services) and declares_dashboard(repo, until=until))}
|
|
293
319
|
|
|
294
320
|
|
|
295
321
|
def notice_line(report: Dict[str, Any], script: Path) -> Optional[str]:
|
|
@@ -364,14 +390,22 @@ def hook_cwd(stdin_text: str) -> Path:
|
|
|
364
390
|
|
|
365
391
|
|
|
366
392
|
def notice() -> int:
|
|
393
|
+
"""The SessionStart hook. Asks first whether the repository owns a service and says nothing, having looked at
|
|
394
|
+
nothing else, when it owns none; every git call and the tree walk share NOTICE_BUDGET_S, and a notice that runs
|
|
395
|
+
out of it says nothing rather than a half-measured line (FAA-69)."""
|
|
396
|
+
until = time.monotonic() + NOTICE_BUDGET_S
|
|
367
397
|
try:
|
|
368
398
|
stdin_text = "" if sys.stdin is None or sys.stdin.isatty() else sys.stdin.read(65536)
|
|
369
|
-
root = repo_root(hook_cwd(stdin_text))
|
|
370
|
-
if root is
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
399
|
+
root = repo_root(hook_cwd(stdin_text), until=until)
|
|
400
|
+
if root is None:
|
|
401
|
+
return 0
|
|
402
|
+
services = services_of(root, until=until)
|
|
403
|
+
if not services:
|
|
404
|
+
return 0
|
|
405
|
+
line = notice_line(status(root, services=services, until=until), Path(__file__).resolve())
|
|
406
|
+
if line:
|
|
407
|
+
print(line)
|
|
408
|
+
except Exception: # noqa: BLE001 - a hook never breaks a session; OutOfTime included
|
|
375
409
|
pass
|
|
376
410
|
return 0
|
|
377
411
|
|