@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
@@ -0,0 +1,402 @@
1
+ """An outbound HTTP client for a service: one deadline across every attempt, bounded retries with jittered backoff,
2
+ `Retry-After` honoured up to a cap, a response size cap, and no secret in an error.
3
+
4
+ response = fabric_http.request("GET", "https://api.example/v1/items", headers={"Authorization": ...},
5
+ deadline_s=20, retries=2)
6
+ response.status, response.headers["content-type"], response.json()
7
+
8
+ Rules (FAA-65 REQ-06):
9
+
10
+ - **What is retried.** A status in `RETRY_STATUSES` (408, 425, 429, 500, 502, 503, 504) or a connection error,
11
+ at most `retries` times. A request is retried only when repeating it is safe: an idempotent method (`GET`, `HEAD`,
12
+ `OPTIONS`, `PUT`, `DELETE`) or a request that carries an `Idempotency-Key` header. Any other request is retried only
13
+ on 429, which says the server refused it before doing anything. Every other status is answered as it came.
14
+ - **When.** The wait before attempt *n* (from 0) is `min(max_backoff_s, backoff_s * 2**n) * (0.5 + random())`; a
15
+ `Retry-After` the server sent (seconds or an HTTP date) replaces it, up to `max_retry_after_s`. A wait that would
16
+ pass the deadline is not taken: the last answer or error stands.
17
+ - **The deadline** covers every attempt and every wait: each attempt gets what is left of it.
18
+ - **The size cap.** A body longer than `max_bytes` is an error, read no further than one byte past the cap.
19
+ - **Secrets.** A plain `http://` URL is refused unless the host is loopback or `insecure=True`: a bearer token never
20
+ crosses a network in clear. An error names the method and the URL without its userinfo, query and fragment
21
+ (`redact_url`); it never carries a request header or a request body.
22
+
23
+ The transport is injected in tests: `transport(method, url, headers, body, timeout_s, max_bytes)` returns
24
+ `(status, headers, body_bytes)` or raises `OSError` (a connection error, `TimeoutError` included) or `HttpError`.
25
+ Standard library only. Part of the service kit (FAA-65, layer L4).
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import datetime as _dt
30
+ import http.client
31
+ import ipaddress
32
+ import json
33
+ import re
34
+ import socket
35
+ import random as _random
36
+ import threading
37
+ import time
38
+ import urllib.error
39
+ import urllib.parse
40
+ import urllib.request
41
+ from typing import Any, Callable, Dict, Mapping, Optional, Tuple
42
+
43
+ import sys as _sys # noqa: E402
44
+ from pathlib import Path as _Path # noqa: E402
45
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
46
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
47
+ from fabric_jsvalue import json_stringify # noqa: E402 - beside this file
48
+
49
+ RETRY_STATUSES = (408, 425, 429, 500, 502, 503, 504)
50
+ IDEMPOTENT_METHODS = ("GET", "HEAD", "OPTIONS", "PUT", "DELETE")
51
+ DEFAULT_MAX_BYTES = 8 * 1024 * 1024
52
+ _TOKEN = frozenset("!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
53
+
54
+ Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float, int], Tuple[int, Dict[str, str], bytes]]
55
+
56
+
57
+ class HttpError(Exception):
58
+ """A request that did not get an answer it could use. `status` is None for a connection error, a timeout, a
59
+ refused URL or a body over the cap; `url` is redacted; `body` holds at most 2048 bytes of an answer's body."""
60
+
61
+ def __init__(self, message: str, *, status: Optional[int] = None, url: str = "", attempts: int = 0,
62
+ retry_after_s: Optional[float] = None, body: bytes = b"", kind: str = "http"):
63
+ super().__init__(message)
64
+ self.status = status
65
+ self.url = url
66
+ self.attempts = attempts
67
+ self.retry_after_s = retry_after_s
68
+ self.body = bytes(body[:2048])
69
+ self.kind = kind # http (an answer), connection, timeout, too-large, refused
70
+
71
+
72
+ class Response:
73
+ """An answer: `status`, `headers` (names lower-cased), `body` (bytes), `attempts`, `url` (redacted)."""
74
+
75
+ def __init__(self, status: int, headers: Mapping[str, str], body: bytes, *, attempts: int = 1, url: str = ""):
76
+ self.status = status
77
+ self.headers = {str(k).lower(): str(v) for k, v in headers.items()}
78
+ self.body = body
79
+ self.attempts = attempts
80
+ self.url = url
81
+
82
+ @property
83
+ def ok(self) -> bool:
84
+ return 200 <= self.status < 300
85
+
86
+ def text(self) -> str:
87
+ return self.body.decode("utf-8", "replace")
88
+
89
+ def json(self) -> Any:
90
+ """The body as JSON, read as `JSON.parse` reads it: a UTF-8 byte order mark skipped, `NaN` and `Infinity`
91
+ refused; anything unreadable (nesting too deep included) is a ValueError."""
92
+ def refuse(token: str) -> Any:
93
+ raise ValueError("%s is not JSON" % token)
94
+ try:
95
+ return json.loads(self.body.decode("utf-8-sig"), parse_constant=refuse)
96
+ except RecursionError:
97
+ raise ValueError("the JSON is nested too deep")
98
+
99
+
100
+ _SURROGATE = re.compile("[\ud800-\udfff]")
101
+
102
+
103
+ def utf8(text: str) -> bytes:
104
+ """Text as UTF-8 the way `TextEncoder` writes it: a lone surrogate becomes U+FFFD instead of an error."""
105
+ return _SURROGATE.sub("\ufffd", text).encode("utf-8")
106
+
107
+
108
+ def json_bytes(value: Any) -> bytes:
109
+ """Compact JSON byte for byte as `JSON.stringify` writes it (numbers, key order, a lone surrogate escaped as
110
+ `\\uXXXX`), in UTF-8; NaN or Infinity refused (`ValueError`) rather than written as null, and a value JSON has no
111
+ form for refused (`TypeError`)."""
112
+ json.dumps(value, allow_nan=False) # raises on NaN, Infinity and types JSON cannot hold
113
+ return json_stringify(value).encode("utf-8")
114
+
115
+
116
+ def redact_url(url: str) -> str:
117
+ """The URL as it may appear in an error or a log line: scheme, host, port and path; no userinfo, query or
118
+ fragment, which is where keys and tokens ride."""
119
+ try:
120
+ parts = urllib.parse.urlsplit(str(url))
121
+ except ValueError:
122
+ return "<unparseable url>"
123
+ host = parts.hostname or ""
124
+ if ":" in host:
125
+ host = "[%s]" % host
126
+ try:
127
+ port = parts.port
128
+ except ValueError:
129
+ port = None
130
+ if port == {"http": 80, "https": 443}.get(parts.scheme.lower()):
131
+ port = None # as a WHATWG URL writes it, so both kits print one URL the same way
132
+ netloc = host.lower() + (":%d" % port if port is not None else "")
133
+ path = parts.path or ("/" if parts.scheme.lower() in ("http", "https") else "")
134
+ return urllib.parse.urlunsplit((parts.scheme.lower(), netloc, path, "", ""))
135
+
136
+
137
+ def _is_loopback(host: str) -> bool:
138
+ """Loopback as a WHATWG URL parser leaves the host: one trailing dot dropped from an IPv4 address, no whitespace,
139
+ and an IPv4-mapped IPv6 address not counted (Node writes it in hex and the Node twin refuses it)."""
140
+ if host == "localhost" or host.endswith(".localhost"):
141
+ return True
142
+ if not host or any(c.isspace() for c in host):
143
+ return False
144
+ try:
145
+ address = ipaddress.ip_address(host)
146
+ return address.is_loopback and not getattr(address, "ipv4_mapped", None)
147
+ except ValueError:
148
+ pass
149
+ host = host[:-1] if host.endswith(".") else host
150
+ try: # the short IPv4 forms a WHATWG URL also reads (127.1, 2130706433, 0x7f.1)
151
+ return socket.inet_aton(host)[0] == 127
152
+ except (OSError, ValueError):
153
+ return False
154
+
155
+
156
+ def check_url(url: str, *, insecure: bool = False) -> None:
157
+ """Refuse what this client never sends: a scheme other than http or https, no host, credentials in the URL, or
158
+ plain http to a host that is not loopback (unless `insecure`)."""
159
+ try:
160
+ parts = urllib.parse.urlsplit(url)
161
+ host = parts.hostname
162
+ parts.port # noqa: B018 - raises on a bad port
163
+ except (ValueError, TypeError):
164
+ raise HttpError("the URL does not parse", url=redact_url(url), kind="refused")
165
+ if parts.scheme not in ("http", "https") or not host:
166
+ raise HttpError("only http and https URLs with a host are sent", url=redact_url(url), kind="refused")
167
+ if parts.username is not None or parts.password is not None:
168
+ raise HttpError("credentials in a URL are refused: send them in a header", url=redact_url(url), kind="refused")
169
+ if parts.scheme == "http" and not insecure and not _is_loopback(host):
170
+ raise HttpError("plain http to a host that is not loopback is refused (insecure=True to allow)",
171
+ url=redact_url(url), kind="refused")
172
+
173
+
174
+ _IMF_FIXDATE = re.compile(r"(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d\d) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) "
175
+ r"(\d{4}) (\d\d):(\d\d):(\d\d) GMT", re.ASCII)
176
+ _MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
177
+
178
+
179
+ def retry_after_s(value: Optional[str], *, now: Optional[float] = None) -> Optional[float]:
180
+ """Seconds a `Retry-After` header asks for (RFC 9110 10.2.3): a non-negative integer, or an IMF-fixdate
181
+ (`Sun, 06 Nov 1994 08:49:37 GMT`) read as the time from `now` (epoch seconds), never below 0. None when absent or
182
+ in any other form; the obsolete date forms are not read."""
183
+ if value is None:
184
+ return None
185
+ text = str(value).strip()
186
+ if re.fullmatch(r"[0-9]{1,10}", text):
187
+ return float(int(text))
188
+ m = _IMF_FIXDATE.fullmatch(text)
189
+ if not m:
190
+ return None
191
+ day, month, year, hh, mm, ss = int(m[2]), _MONTHS.index(m[3]) + 1, int(m[4]), int(m[5]), int(m[6]), int(m[7])
192
+ if hh > 23 or mm > 59 or ss > 60 or year < 1000: # a year before 1000 is no HTTP date (and Date.UTC maps 0-99)
193
+ return None
194
+ try:
195
+ when = _dt.datetime(year, month, day, hh, mm, min(ss, 59), tzinfo=_dt.timezone.utc).timestamp()
196
+ except ValueError: # a day the month does not have
197
+ return None
198
+ return float(max(0.0, when - (time.time() if now is None else now)))
199
+
200
+
201
+ def backoff_s(attempt: int, *, base_s: float, max_s: float, rand: float) -> float:
202
+ """The jittered wait before retry `attempt` (from 0): `min(max_s, base_s * 2**attempt) * (0.5 + rand)`."""
203
+ return min(max_s, base_s * (2 ** attempt)) * (0.5 + rand)
204
+
205
+
206
+ def may_retry(method: str, headers: Mapping[str, str], status: Optional[int]) -> bool:
207
+ """Whether an answer with `status` (None: a connection error or a timeout) may be retried for this request."""
208
+ if status is not None and status not in RETRY_STATUSES:
209
+ return False
210
+ safe = method.upper() in IDEMPOTENT_METHODS or any(str(k).lower() == "idempotency-key" for k in headers)
211
+ return safe or status == 429
212
+
213
+
214
+ def _check_headers(headers: Mapping[str, str]) -> Dict[str, str]:
215
+ out = {}
216
+ for name, value in headers.items():
217
+ name, value = str(name), str(value)
218
+ if not name or any(c not in _TOKEN for c in name):
219
+ raise HttpError("a header name is not an HTTP token", kind="refused")
220
+ if any(c in value for c in "\r\n\0"):
221
+ raise HttpError("a header value carries a line break or NUL", kind="refused")
222
+ out[name] = value
223
+ return out
224
+
225
+
226
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
227
+ def redirect_request(self, *args, **kwargs): # a redirect is answered as it came, never followed
228
+ return None
229
+
230
+
231
+ class _Watchdog:
232
+ """Shuts a request's socket down when its deadline passes, so a server that trickles its headers or a chunk-size
233
+ line cannot hold the caller past it (a socket timeout bounds one read, not a whole response)."""
234
+
235
+ def __init__(self, end: float):
236
+ self.end = end
237
+ self.fired = False
238
+ self._timer: Optional[threading.Timer] = None
239
+
240
+ def arm(self, sock: Any) -> None:
241
+ def fire() -> None:
242
+ self.fired = True
243
+ try:
244
+ sock.shutdown(socket.SHUT_RDWR)
245
+ except OSError:
246
+ pass
247
+ self._timer = threading.Timer(max(0.0, self.end - time.monotonic()), fire)
248
+ self._timer.daemon = True
249
+ self._timer.start()
250
+
251
+ def cancel(self) -> None:
252
+ if self._timer is not None:
253
+ self._timer.cancel()
254
+
255
+
256
+ def _watched(base: type, watchdog: _Watchdog) -> type:
257
+ class Watched(base): # type: ignore[misc, valid-type]
258
+ def connect(self) -> None:
259
+ super().connect()
260
+ watchdog.arm(self.sock)
261
+ return Watched
262
+
263
+
264
+ def _opener(watchdog: _Watchdog) -> urllib.request.OpenerDirector:
265
+ class Http(urllib.request.HTTPHandler):
266
+ def http_open(self, req):
267
+ return self.do_open(_watched(http.client.HTTPConnection, watchdog), req)
268
+
269
+ class Https(urllib.request.HTTPSHandler):
270
+ def https_open(self, req):
271
+ return self.do_open(_watched(http.client.HTTPSConnection, watchdog), req, context=self._context)
272
+ return urllib.request.build_opener(_NoRedirect, Http, Https)
273
+
274
+
275
+ def urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout_s: float,
276
+ max_bytes: int) -> Tuple[int, Dict[str, str], bytes]:
277
+ """The default transport: urllib, no redirects followed, the body read to one byte past `max_bytes`."""
278
+ req = urllib.request.Request(url, data=body, method=method, headers=headers)
279
+ end = time.monotonic() + timeout_s # one deadline from the first byte sent, headers and body included
280
+ watchdog = _Watchdog(end)
281
+ try:
282
+ return _send(req, timeout_s, max_bytes, end, watchdog)
283
+ except (OSError, http.client.HTTPException) as exc:
284
+ if watchdog.fired:
285
+ raise TimeoutError("the answer did not arrive within the deadline")
286
+ raise exc if isinstance(exc, OSError) else ConnectionError(type(exc).__name__)
287
+ finally:
288
+ watchdog.cancel()
289
+
290
+
291
+ def _send(req: urllib.request.Request, timeout_s: float, max_bytes: int, end: float,
292
+ watchdog: _Watchdog) -> Tuple[int, Dict[str, str], bytes]:
293
+ try:
294
+ resp = _opener(watchdog).open(req, timeout=timeout_s)
295
+ except urllib.error.HTTPError as answer: # a status outside 2xx is an answer, not a failure of the transport
296
+ resp = answer
297
+ except urllib.error.URLError as exc:
298
+ reason = exc.reason
299
+ raise reason if isinstance(reason, OSError) else ConnectionError(str(reason))
300
+ except (http.client.HTTPException, ValueError) as exc:
301
+ raise ConnectionError(type(exc).__name__)
302
+ try:
303
+ chunks, size = [], 0
304
+ while size <= max_bytes:
305
+ left = end - time.monotonic()
306
+ if left <= 0:
307
+ raise TimeoutError("the body did not arrive within the deadline")
308
+ sock = getattr(getattr(getattr(resp, "fp", None), "raw", None), "_sock", None)
309
+ if sock is not None:
310
+ sock.settimeout(left) # each read waits at most what is left of the deadline
311
+ chunk = resp.read1(min(65536, max_bytes + 1 - size))
312
+ if watchdog.fired: # Linux reads a shut socket as a clean end: a cut answer is never a whole one
313
+ raise TimeoutError("the body did not arrive within the deadline")
314
+ if not chunk:
315
+ break
316
+ chunks.append(chunk)
317
+ size += len(chunk)
318
+ data = b"".join(chunks)[:max_bytes + 1]
319
+ return resp.status if hasattr(resp, "status") else resp.code, dict(resp.headers.items()), data
320
+ except http.client.HTTPException as exc:
321
+ raise ConnectionError(type(exc).__name__)
322
+ finally:
323
+ resp.close()
324
+
325
+
326
+ def request(method: str, url: str, *, headers: Optional[Mapping[str, str]] = None, body: Any = None,
327
+ deadline_s: float = 30.0, retries: int = 2, max_bytes: int = DEFAULT_MAX_BYTES, backoff_base_s: float = 0.5,
328
+ max_backoff_s: float = 8.0, max_retry_after_s: float = 30.0, insecure: bool = False,
329
+ transport: Optional[Transport] = None, sleep: Callable[[float], None] = time.sleep,
330
+ clock: Callable[[], float] = time.monotonic, rand: Callable[[], float] = _random.random) -> Response:
331
+ """Send one request under the rules above and return its answer, whatever its status; raise `HttpError` when
332
+ no answer came (a connection error, the deadline, a body over the cap, a refused URL). A `dict` or `list` body is
333
+ sent as JSON with `Content-Type: application/json` unless the caller set one; `bytes` and `str` go as they are."""
334
+ method = str(method).upper()
335
+ if not method or any(c not in _TOKEN for c in method):
336
+ raise HttpError("the method is not an HTTP token", kind="refused")
337
+ if not (isinstance(retries, int) and 0 <= retries <= 10) or isinstance(retries, bool):
338
+ raise HttpError("retries is an integer from 0 to 10", kind="refused")
339
+ if not (deadline_s > 0 and max_bytes > 0):
340
+ raise HttpError("deadline_s and max_bytes are positive", kind="refused")
341
+ check_url(url, insecure=insecure)
342
+ sent = _check_headers(headers or {})
343
+ data: Optional[bytes]
344
+ if body is None:
345
+ data = None
346
+ elif isinstance(body, (dict, list)):
347
+ try:
348
+ data = json_bytes(body)
349
+ except (ValueError, TypeError):
350
+ raise HttpError("the body holds a value JSON cannot write (NaN, Infinity or a non-JSON type)", kind="refused")
351
+ if not any(k.lower() == "content-type" for k in sent):
352
+ sent["Content-Type"] = "application/json"
353
+ elif isinstance(body, str):
354
+ data = utf8(body)
355
+ elif isinstance(body, (bytes, bytearray)):
356
+ data = bytes(body)
357
+ else:
358
+ raise HttpError("the body is bytes, str, a dict or a list", kind="refused")
359
+ send = transport or urllib_transport
360
+ where = redact_url(url)
361
+ end = clock() + deadline_s
362
+ attempt = 0
363
+ while True:
364
+ left = end - clock()
365
+ if left <= 0:
366
+ raise HttpError("%s %s: the deadline passed before an answer" % (method, where), url=where,
367
+ attempts=attempt, kind="timeout")
368
+ attempt += 1
369
+ status: Optional[int] = None
370
+ failure: Optional[HttpError] = None
371
+ try:
372
+ status, got_headers, got = send(method, url, dict(sent), data, left, max_bytes)
373
+ except HttpError:
374
+ raise
375
+ except (TimeoutError, socket.timeout): # socket.timeout is no TimeoutError before Python 3.10
376
+ failure = HttpError("%s %s: timed out" % (method, where), url=where, attempts=attempt, kind="timeout")
377
+ except Exception as exc: # noqa: BLE001 - as the Node twin: whatever the transport throws is a connection error
378
+ failure = HttpError("%s %s: connection error (%s)" % (method, where, type(exc).__name__), url=where,
379
+ attempts=attempt, kind="connection")
380
+ if failure is None:
381
+ if len(got) > max_bytes:
382
+ raise HttpError("%s %s: the answer is over %d bytes" % (method, where, max_bytes), status=status,
383
+ url=where, attempts=attempt, kind="too-large")
384
+ answer = Response(status, got_headers, got, attempts=attempt, url=where)
385
+ if not may_retry(method, sent, status) or attempt > retries:
386
+ return answer
387
+ wait = retry_after_s(answer.headers.get("retry-after"))
388
+ if wait is not None:
389
+ if wait > max_retry_after_s:
390
+ return answer # the server asks for longer than this caller will wait
391
+ else:
392
+ wait = backoff_s(attempt - 1, base_s=backoff_base_s, max_s=max_backoff_s, rand=rand())
393
+ else:
394
+ if not may_retry(method, sent, None) or attempt > retries:
395
+ raise failure
396
+ answer = None
397
+ wait = backoff_s(attempt - 1, base_s=backoff_base_s, max_s=max_backoff_s, rand=rand())
398
+ if clock() + wait >= end:
399
+ if answer is not None:
400
+ return answer
401
+ raise failure # type: ignore[misc]
402
+ sleep(wait)
@@ -44,7 +44,7 @@ import fabric_service as _fs # noqa: E402 - beside this file: token files, atom
44
44
  HUB_PROTOCOL = "fabric-hub/0.1"
45
45
  MCP_PROTOCOL_VERSION = "2025-06-18"
46
46
  READ_CAP = 64 * 1024
47
- LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{4,5})")
47
+ LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:([0-9]{4,5})")
48
48
  TOKEN = re.compile(r"[A-Za-z0-9_-]{43}")
49
49
  UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
50
50
  CAPABILITY = re.compile(r"[a-z][a-z0-9._-]{1,127}")
@@ -163,8 +163,12 @@ def locate_hub(root: Optional[Path] = None, alive: Callable[[int], bool] = pid_a
163
163
  if estate is not None and isinstance(doc.get("hubKey"), dict):
164
164
  import fabric_estate # beside this file
165
165
  key = doc["hubKey"]
166
+ # A missing or null applied is "none known", as fabric-hub.mjs reads it (`estate.applied ?? undefined`); only a
167
+ # present one is checked, and hub_key_certified refuses an explicit null of its own (decision D3).
168
+ applied = estate.get("applied")
166
169
  verdict = fabric_estate.hub_key_certified(estate.get("record"), key.get("kid"), key.get("x"),
167
- time.time() if now is None else now, estate.get("applied"))
170
+ time.time() if now is None else now,
171
+ fabric_estate._MISSING if applied is None else applied)
168
172
  if not verdict.get("certified"):
169
173
  raise HubError("hub-key-refused", "The estate record does not certify the hub's key (%s)." % verdict.get("detail"),
170
174
  data={"fault": verdict.get("fault"), "detail": verdict.get("detail")})