@passioncode-ai/fabric-agent-adapter 0.43.0 → 0.44.1

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 (31) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/package.json +1 -1
  3. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  4. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  5. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -2
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +7 -3
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +20 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +8 -5
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +9 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +186 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +6 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +264 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +31 -254
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package-schema.mjs +100 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -89
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +2 -2
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +448 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +6 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +54 -369
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +390 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +20 -187
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package_schema.py +207 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +234 -19
  27. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  28. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  29. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
  30. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +4 -3
  31. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +3 -1
@@ -0,0 +1,448 @@
1
+ """What an agent uses: `fabric-connections/0.1`, the extension keyed extensions/connections/0.1 (DEC-0096, FAC-SEM-127).
2
+ `connections_view` reads a well-known document as the contract's `connectionsView` does (it never raises);
3
+ `connections_extension` builds the `extensions` entry a service declares, `state` from its own checks and its
4
+ `degraded[]`. Part of the service kit (FAA-62, layer L3). Standard library only."""
5
+ from __future__ import annotations
6
+
7
+ import ipaddress
8
+ import re
9
+ import urllib.parse
10
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
11
+
12
+ import sys as _sys # noqa: E402
13
+ from pathlib import Path as _Path # noqa: E402
14
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
15
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
16
+
17
+ from fabric_base import ServiceError # noqa: E402
18
+ from fabric_secrets import CREDENTIAL_PAIRS, CREDENTIAL_WORDS, JWT_SHAPE, _long_secret, raw_values # noqa: E402
19
+
20
+ CONNECTIONS_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/connections/0.1"
21
+ KINDS = ("service", "model", "connector", "store", "channel")
22
+ STATES = ("ok", "degraded", "down", "unknown")
23
+ MAX_CONNECTIONS = 64
24
+ _MEMBERS = ("id", "kind", "target", "capability", "state", "label", "purpose", "reason", "lastAt")
25
+ _STRING_MEMBERS = ("id", "target", "capability", "label", "purpose", "reason")
26
+
27
+ # ECMAScript's \s, which the schema's patterns and the contract's splits use: Python's \s differs (it takes \x1c-\x1f
28
+ # and \x85, and leaves U+FEFF out).
29
+ _JS_SPACE = "\t\n\x0b\x0c\r    - 

   "
30
+ _SPACES = re.compile("[%s]+" % _JS_SPACE)
31
+ _ID = re.compile(r"[a-z0-9][a-z0-9._-]{0,63}")
32
+ _TARGET = re.compile("[^%s]+" % _JS_SPACE)
33
+ _SERVICE_TARGET = re.compile(r"[a-z0-9][a-z0-9-]{0,63}(?:\.[a-z0-9][a-z0-9-]{0,63})?")
34
+ _CAPABILITY = re.compile(r"[a-z][a-z0-9._-]{1,127}")
35
+ _LINE = re.compile("[^\n\r]+")
36
+
37
+ # ajv-formats `date-time` in its full mode (the contract's validator): a date, `t` or a space, a time with a zone.
38
+ _DT_SEPARATOR = re.compile("[tT%s]" % _JS_SPACE)
39
+ _DATE = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})")
40
+ _TIME = re.compile(r"([0-9]{2}):([0-9]{2}):([0-9]{2}(?:\.[0-9]+)?)(z|([+-])([0-9]{2})(?::?([0-9]{2}))?)?", re.IGNORECASE | re.ASCII)
41
+ _DAYS = (0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
42
+
43
+ # The contract's KEY_SHAPE (src/connections.ts): a provider key where it starts a word, case-sensitive prefixes, the
44
+ # `sk-` rules and a Telegram bot token.
45
+ _KEY_SHAPE = re.compile(
46
+ r"(?:^|[^A-Za-z0-9])(?:sk-(?=[A-Za-z0-9_-]*[0-9])(?:(?=[A-Za-z0-9_-]*[A-Z])[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{32,})"
47
+ r"|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}"
48
+ r"|(?:bot)?[0-9]{6,12}:(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[a-z])[A-Za-z0-9_-]{34,35}(?![A-Za-z0-9_-]))")
49
+ _URL_SCHEME = re.compile(r"[a-z][a-z0-9+.-]*://", re.IGNORECASE | re.ASCII)
50
+ _CLOSING = ")]}>\"'.,;:!?"
51
+ _TRIED_BACK = 1
52
+
53
+
54
+ def _date(value: str) -> bool:
55
+ match = _DATE.fullmatch(value)
56
+ if not match:
57
+ return False
58
+ year, month, day = (int(g) for g in match.groups())
59
+ leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
60
+ return 1 <= month <= 12 and 1 <= day <= (29 if month == 2 and leap else _DAYS[month])
61
+
62
+
63
+ def _time(value: str) -> bool:
64
+ match = _TIME.fullmatch(value)
65
+ if not match:
66
+ return False
67
+ hour, minute, second = int(match.group(1)), int(match.group(2)), float(match.group(3))
68
+ zone, sign = match.group(4), -1 if match.group(5) == "-" else 1
69
+ zone_h, zone_m = int(match.group(6) or 0), int(match.group(7) or 0)
70
+ if zone_h > 23 or zone_m > 59 or not zone:
71
+ return False
72
+ if hour <= 23 and minute <= 59 and second < 60:
73
+ return True
74
+ utc_min = minute - zone_m * sign # a leap second, at 23:59 UTC only
75
+ utc_hour = hour - zone_h * sign - (1 if utc_min < 0 else 0)
76
+ return utc_hour in (23, -1) and utc_min in (59, -1) and second < 61
77
+
78
+
79
+ def date_time(value: str) -> bool:
80
+ """The `date-time` format as the contract's validator (ajv-formats, full mode) reads it."""
81
+ parts = _DT_SEPARATOR.split(value)
82
+ return len(parts) == 2 and _date(parts[0]) and _time(parts[1])
83
+
84
+
85
+ def _length(value: str) -> int:
86
+ return len(value) # code points, as ajv counts them
87
+
88
+
89
+ def _shape_problems(value: Any) -> List[str]:
90
+ """service-connections-extension.schema.json, read as the contract's validator reads it."""
91
+ if not isinstance(value, dict):
92
+ return ["/ type: must be object"]
93
+ # A member's name is never echoed: a secret-shaped name would reach the error text and a probe's evidence.
94
+ problems = ["/ additionalProperties: a member other than connections" for key in value if key != "connections"]
95
+ if "connections" not in value:
96
+ return problems + ["/ required: connections"]
97
+ connections = value["connections"]
98
+ if not isinstance(connections, list):
99
+ return problems + ["/connections type: must be array"]
100
+ if len(connections) > MAX_CONNECTIONS:
101
+ problems.append("/connections maxItems: at most %d" % MAX_CONNECTIONS)
102
+ for index, item in enumerate(connections):
103
+ at = "/connections/%d" % index
104
+ if not isinstance(item, dict):
105
+ problems.append("%s type: must be object" % at)
106
+ continue
107
+ problems += ["%s required: %s" % (at, key) for key in ("id", "kind", "target", "state") if key not in item]
108
+ problems += ["%s additionalProperties: a member the schema does not name" % at for key in item if key not in _MEMBERS]
109
+ for key in _STRING_MEMBERS + ("lastAt",):
110
+ if key in item and not isinstance(item[key], str):
111
+ problems.append("%s/%s type: must be string" % (at, key))
112
+ text = {k: v for k, v in item.items() if isinstance(v, str)}
113
+ if "id" in text and not _ID.fullmatch(text["id"]):
114
+ problems.append("%s/id pattern" % at)
115
+ if "kind" in item and item["kind"] not in KINDS:
116
+ problems.append("%s/kind enum" % at)
117
+ if "state" in item and item["state"] not in STATES:
118
+ problems.append("%s/state enum" % at)
119
+ if "target" in text and not (1 <= _length(text["target"]) <= 200 and _TARGET.fullmatch(text["target"])):
120
+ problems.append("%s/target length or pattern" % at)
121
+ if "capability" in text and not _CAPABILITY.fullmatch(text["capability"]):
122
+ problems.append("%s/capability pattern" % at)
123
+ for key in ("label", "purpose", "reason"):
124
+ if key in text and not (1 <= _length(text[key]) <= 300 and _LINE.fullmatch(text[key])):
125
+ problems.append("%s/%s length or pattern" % (at, key))
126
+ if "lastAt" in text and not date_time(text["lastAt"]):
127
+ problems.append("%s/lastAt format: date-time" % at)
128
+ if "kind" in item:
129
+ if item["kind"] != "service" and "capability" in item:
130
+ problems.append("%s/capability: only a service connection names a capability" % at)
131
+ if item["kind"] == "service" and "target" in text and not _SERVICE_TARGET.fullmatch(text["target"]):
132
+ problems.append("%s/target pattern: a service target is an id.instance key or a product id" % at)
133
+ return problems
134
+
135
+
136
+ def _urls_in(value: str) -> List[str]:
137
+ """The URLs a member may hold, from every scheme in each whitespace-delimited piece, without the closing brackets,
138
+ quotes and punctuation that end a sentence, and with _TRIED_BACK of them put back (`]` closes an IPv6 host)."""
139
+ urls: List[str] = []
140
+ for piece in _SPACES.split(value):
141
+ for match in _URL_SCHEME.finditer(piece):
142
+ url = piece[match.start():]
143
+ end = len(url)
144
+ while end > 0 and url[end - 1] in _CLOSING:
145
+ end -= 1
146
+ back = 0
147
+ while back <= _TRIED_BACK and end + back <= len(url):
148
+ urls.append(url[:end + back])
149
+ back += 1
150
+ return urls
151
+
152
+
153
+ def _long_run(value: str) -> bool:
154
+ """A long secret-shaped run in a piece read whole; in a piece that holds a URL, the text before its first scheme's
155
+ `://` (the URL itself is read by secret_url)."""
156
+ for piece in _SPACES.split(value):
157
+ found = _URL_SCHEME.search(piece)
158
+ cut = piece.find("://", found.start()) if found else -1
159
+ if _long_secret(piece if cut < 0 else piece[:cut]):
160
+ return True
161
+ return False
162
+
163
+
164
+ # The shared detector (DEC-0044) as the contract's ECMAScript reads it: ASCII case folding, ECMAScript's \s and `.`,
165
+ # an ASCII word boundary. The kit's fabric_secrets keeps Python's semantics for its other callers (LC-21); FAC-SEM-127
166
+ # is held to the reference, so this module reads with the reference's (blind reading of 0.44.0, finding 1).
167
+ _NOT_LINE_END = "[^\n\r\u2028\u2029]"
168
+ _SECRET_VALUE = re.compile(r"(^bearer[%s]%s+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}"
169
+ r"|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})" % (_JS_SPACE, _NOT_LINE_END), re.IGNORECASE | re.ASCII)
170
+ _BEARER = re.compile(r"(?<![A-Za-z0-9_])bearer[%s]+(?=[A-Za-z0-9._~+/-]*[0-9])[A-Za-z0-9._~+/-]{12,}=*" % _JS_SPACE,
171
+ re.IGNORECASE | re.ASCII)
172
+ _PATH_LIKE = re.compile(r"(?:[/~.]|[A-Za-z]:[\\/]|[a-z][a-z0-9+.-]*://)", re.IGNORECASE | re.ASCII)
173
+ _ARGUMENT_SPLIT = re.compile("[%s=]" % _JS_SPACE)
174
+
175
+
176
+ def _credential_name(name: str) -> bool:
177
+ """fabric_secrets.credential_name with ECMAScript's whitespace in the word split (U+FEFF splits a name there)."""
178
+ spaced = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name)
179
+ spaced = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", spaced)
180
+ split = [w.lower() for w in re.split("[%s._-]+" % _JS_SPACE, spaced) if w]
181
+ words = split + [w[:-1] for w in split if len(w) > 2 and w.endswith("s")]
182
+ if any(w in CREDENTIAL_WORDS for w in words) or (len(split) == 1 and any(w in CREDENTIAL_PAIRS for w in words)):
183
+ return True
184
+ return any(word in words and any(p in words for p in partners) for word, partners in CREDENTIAL_PAIRS.items())
185
+
186
+
187
+ def _secret_value(value: str) -> bool:
188
+ return bool(JWT_SHAPE.search(value) or _SECRET_VALUE.search(value) or _long_secret(value))
189
+
190
+
191
+ def _path_like(piece: str) -> bool:
192
+ return bool(_PATH_LIKE.match(piece)) or (
193
+ bool(re.search(r"[\\/]", piece))
194
+ and (bool(re.search(r"\.[A-Za-z0-9]{1,8}\Z", piece))
195
+ or any(re.fullmatch(r"[a-z][a-z0-9_-]{2,}", segment) for segment in re.split(r"[\\/]", piece))))
196
+
197
+
198
+ def _secret_argument(value: str) -> bool:
199
+ if JWT_SHAPE.search(value) or _SECRET_VALUE.search(value) or _BEARER.search(value):
200
+ return True
201
+ return any(any(_long_secret(part) for part in re.split(r"[\\/]", piece)) if _path_like(piece) else _long_secret(piece)
202
+ for piece in _ARGUMENT_SPLIT.split(value))
203
+
204
+
205
+ _SPECIAL = ("http", "https", "ws", "wss", "ftp", "file")
206
+ _FORBIDDEN_HOST = set("\x00\t\n\r #/:<>?@[\\]^|")
207
+ _FORBIDDEN_DOMAIN = _FORBIDDEN_HOST | set(chr(c) for c in range(0x20)) | {"%", "\x7f"}
208
+
209
+
210
+ def _ipv4_number(part: str) -> Optional[int]:
211
+ if part == "":
212
+ return None # an empty part fails WHATWG's IPv4 parser (`1..2`); `0x` alone is 0
213
+ base = 10
214
+ if part[:2].lower() == "0x":
215
+ part, base = part[2:], 16
216
+ elif len(part) > 1 and part[0] == "0":
217
+ part, base = part[1:], 8
218
+ if part == "":
219
+ return 0
220
+ digits = {10: "0123456789", 16: "0123456789abcdefABCDEF", 8: "01234567"}[base]
221
+ return int(part, base) if all(c in digits for c in part) else None
222
+
223
+
224
+ def _domain_ok(host: str) -> bool:
225
+ """The WHATWG host parser on a special scheme's host, enough to know when `new URL` refuses it."""
226
+ if host.startswith("["):
227
+ return host.endswith("]") and _ipv6_ok(host[1:-1])
228
+ if re.search(r"%(?![0-9A-Fa-f]{2})", host) is None:
229
+ try:
230
+ host = urllib.parse.unquote_to_bytes(host).decode("utf-8")
231
+ except UnicodeDecodeError:
232
+ return False
233
+ if any(c in _FORBIDDEN_DOMAIN for c in host):
234
+ return False
235
+ # A non-ASCII host goes through UTS46 in WHATWG; this module does not port it. When unsure, the host is taken as
236
+ # valid, so its URL's credentials are read: the doubt then hides a list rather than publish one (round-2 reading).
237
+ if any(ord(c) > 0x7f for c in host):
238
+ return True
239
+ labels = host.split(".")
240
+ if labels and labels[-1] == "" and len(labels) > 1:
241
+ labels = labels[:-1]
242
+ last = labels[-1] if labels else ""
243
+ if not (last and (last.isdigit() and last.isascii() or re.fullmatch(r"0[xX][0-9A-Fa-f]*", last))):
244
+ return host != ""
245
+ numbers = [_ipv4_number(label) for label in labels]
246
+ if len(numbers) > 4 or any(n is None for n in numbers):
247
+ return False
248
+ return all(n <= 255 for n in numbers[:-1]) and numbers[-1] < 256 ** (5 - len(numbers))
249
+
250
+
251
+ def _ipv6_ok(text: str) -> bool:
252
+ if "%" in text:
253
+ return False # a zone id: `ipaddress` takes one, WHATWG does not
254
+ try:
255
+ ipaddress.IPv6Address(text)
256
+ return True
257
+ except ValueError:
258
+ return False
259
+
260
+
261
+ _SURROGATE = re.compile("[\ud800-\udfff]")
262
+ _QUERY_ENCODE = set(chr(c) for c in range(0x21)) | set('"#<>')
263
+ _FRAGMENT_ENCODE = set(chr(c) for c in range(0x21)) | set('"<>`')
264
+
265
+
266
+ def _serialize(text: str, encode: set) -> str:
267
+ """A query or fragment as WHATWG serializes it (`url.search`, `url.hash`): the set's ASCII and every code point
268
+ above U+007E as UTF-8 percent-escapes in upper-case hex; `%` stays as written (round-3 reading of 0.44.0)."""
269
+ out = []
270
+ for c in text:
271
+ if c in encode or ord(c) > 0x7e:
272
+ out.append("".join("%%%02X" % b for b in c.encode("utf-8")))
273
+ else:
274
+ out.append(c)
275
+ return "".join(out)
276
+
277
+
278
+ def _whatwg_parts(value: str) -> Optional[Dict[str, str]]:
279
+ """The password, query and fragment `new URL(value)` gives, or None where it throws. A subset of the WHATWG URL
280
+ parser: an absolute URL with `//` authority, as the members that reach it are (_urls_in starts each at a scheme)."""
281
+ # A JavaScript string becomes a USV string first: an unpaired surrogate is U+FFFD (json.loads keeps one).
282
+ value = _SURROGATE.sub("\ufffd", value)
283
+ value = re.sub(r"[\t\n\r]", "", value.strip("".join(chr(c) for c in range(0x21))))
284
+ match = re.match(r"([A-Za-z][A-Za-z0-9+.-]*):", value)
285
+ if not match:
286
+ return None
287
+ scheme, rest = match.group(1).lower(), value[match.end():]
288
+ special = scheme in _SPECIAL
289
+ if special:
290
+ cut = min([i for i in (rest.find("?"), rest.find("#")) if i >= 0] or [len(rest)])
291
+ rest = rest[:cut].replace("\\", "/") + rest[cut:] # a backslash is a slash before the query only
292
+ if not rest.startswith("//"):
293
+ return {"password": "", "query": "", "fragment": ""} if not special else None
294
+ rest = rest[2:].lstrip("/") if special and scheme != "file" else rest[2:]
295
+ end = len(rest)
296
+ for stop in "/?#":
297
+ at = rest.find(stop)
298
+ if at >= 0:
299
+ end = min(end, at)
300
+ authority, tail = rest[:end], rest[end:]
301
+ fragment = _serialize(tail.split("#", 1)[1], _FRAGMENT_ENCODE) if "#" in tail else ""
302
+ before = tail.split("#", 1)[0]
303
+ query = _serialize(before.split("?", 1)[1], _QUERY_ENCODE | ({"'"} if special else set())) if "?" in before else ""
304
+ if scheme == "file":
305
+ # A drive letter (`C:`, `c|`) is an empty host and the start of the path.
306
+ if authority == "" or re.fullmatch(r"[A-Za-z][:|]", authority) or _domain_ok(authority):
307
+ return {"password": "", "query": query, "fragment": fragment}
308
+ return None
309
+ userinfo, _, hostport = authority.rpartition("@")
310
+ password = userinfo.split(":", 1)[1] if ":" in userinfo else ""
311
+ if hostport.startswith("["):
312
+ close = hostport.find("]")
313
+ if close < 0:
314
+ return None
315
+ host, after = hostport[:close + 1], hostport[close + 1:]
316
+ if after and not after.startswith(":"):
317
+ return None
318
+ port = after[1:]
319
+ else:
320
+ host, _, port = hostport.partition(":")
321
+ if port and not (port.isascii() and port.isdigit() and int(port) <= 65535):
322
+ return None
323
+ if special:
324
+ if host == "" or not _domain_ok(host):
325
+ return None
326
+ elif host.startswith("["):
327
+ if not _ipv6_ok(host[1:-1]):
328
+ return None
329
+ elif any(c in _FORBIDDEN_HOST for c in host):
330
+ return None
331
+ elif host == "" and ("@" in authority or port):
332
+ return None
333
+ return {"password": password, "query": query, "fragment": fragment}
334
+
335
+
336
+ def _raw_values(query: str) -> Dict[str, List[str]]:
337
+ """fabric_secrets.raw_values with the name read as URLSearchParams reads it: one leading `?` of the raw piece is
338
+ dropped before decoding (`??token` names `?token`, `%3Ftoken` names `?token`; round-3 reading)."""
339
+ values: Dict[str, List[str]] = {}
340
+ for piece in query.split("&"):
341
+ if piece == "":
342
+ continue
343
+ at = piece.find("=")
344
+ raw_name = piece if at < 0 else piece[:at]
345
+ merged = raw_values(raw_name[1:] if raw_name.startswith("?") else raw_name)
346
+ name = next(iter(merged), "")
347
+ values.setdefault(name, []).extend(raw_values("x" + piece[at:])["x"] if at >= 0 else [""])
348
+ return values
349
+
350
+
351
+ def _secret_url(value: str) -> bool:
352
+ """The contract's secretUrl (DEC-0044): what _secret_argument finds, a password, or a query or fragment value
353
+ named or shaped like a credential, in a URL WHATWG parses; one it refuses is read as an argument only."""
354
+ if _secret_argument(value):
355
+ return True
356
+ parts = _whatwg_parts(value)
357
+ if parts is None:
358
+ return False
359
+ if parts["password"]:
360
+ return True
361
+ return any((raw != "" and _credential_name(name)) or _secret_value(raw)
362
+ for part in (parts["query"], parts["fragment"]) for name, raws in _raw_values(part).items() for raw in raws)
363
+
364
+
365
+ def _credential(value: str, runs: bool) -> bool:
366
+ return bool(JWT_SHAPE.search(value) or _KEY_SHAPE.search(value) or _BEARER.search(value)
367
+ or (runs and _long_run(value)) or any(_secret_url(url) for url in _urls_in(value)))
368
+
369
+
370
+ def _beyond_shape(connections: Sequence[Dict[str, Any]]) -> List[str]:
371
+ """What the schema cannot state: an id named twice, a credential in any string member."""
372
+ problems: List[str] = []
373
+ seen = set()
374
+ for index, connection in enumerate(connections):
375
+ if connection["id"] in seen:
376
+ problems.append("/connections/%d/id duplicate: it is named twice" % index)
377
+ seen.add(connection["id"])
378
+ for member in _STRING_MEMBERS:
379
+ value = connection.get(member)
380
+ runs = member not in ("id", "capability") and not (member == "target" and connection.get("kind") == "model")
381
+ if isinstance(value, str) and _credential(value, runs):
382
+ problems.append("/connections/%d/%s credential: a connection never carries a credential" % (index, member))
383
+ return problems
384
+
385
+
386
+ def connections_view(well_known: Any) -> Dict[str, Any]:
387
+ """A host's reading of a well-known document's connections (the contract's connectionsView): never raises. Without
388
+ the key nothing is declared; an invalid value (the schema, a repeated id, a credential) is not declared and is
389
+ `reported`; a valid list comes back with `failing` set on each connection a `degraded[].source` names."""
390
+ none: Dict[str, Any] = {"declared": False, "reported": False, "connections": [], "errors": []}
391
+ extensions = well_known.get("extensions") if isinstance(well_known, dict) else None
392
+ if not isinstance(extensions, dict) or CONNECTIONS_EXTENSION_KEY not in extensions:
393
+ return none
394
+ value = extensions[CONNECTIONS_EXTENSION_KEY]
395
+ errors = _shape_problems(value)
396
+ if not errors:
397
+ errors = _beyond_shape(value["connections"])
398
+ if errors:
399
+ return dict(none, reported=True, errors=errors)
400
+ degraded = well_known.get("degraded")
401
+ failing = {entry["source"] for entry in (degraded if isinstance(degraded, list) else [])
402
+ if isinstance(entry, dict) and isinstance(entry.get("source"), str)}
403
+ return {"declared": True, "reported": False, "errors": [],
404
+ "connections": [dict(c, failing=c["id"] in failing) for c in value["connections"]]}
405
+
406
+
407
+ def connections_rules(well_known: Any) -> List[Dict[str, str]]:
408
+ """FAC-SEM-127: the connections a well-known document declares are valid, each id once, no credential."""
409
+ view = connections_view(well_known)
410
+ at = "/extensions/" + CONNECTIONS_EXTENSION_KEY.replace("~", "~0").replace("/", "~1")
411
+ return [{"code": "FAC-SEM-127", "instancePath": at,
412
+ "message": "the connections declaration is invalid (%s); a host treats it as not declared and reports it "
413
+ "(DEC-0096)" % error} for error in view["errors"]]
414
+
415
+
416
+ def connections_extension(declared: Iterable[Mapping[str, Any]], degraded: Sequence[Mapping[str, Any]] = (),
417
+ states: Optional[Mapping[str, str]] = None) -> Dict[str, Any]:
418
+ """The `extensions` entry a service declares (DEC-0096): what it uses, each connection's `state` from the service's
419
+ own checks (`states`, by id), else `degraded` when a `degraded[].source` names it, else `unknown`. A declared
420
+ `state` is kept. Raises ServiceError on a declaration a host would hide (the schema, a repeated id, a credential):
421
+ a service never publishes one that reads as "declared nothing"."""
422
+ if declared is None or isinstance(declared, (str, bytes, Mapping)) or not isinstance(declared, Iterable):
423
+ raise ServiceError("Connections declaration: declared is a list of connections.")
424
+ if degraded is None:
425
+ degraded = ()
426
+ if states is None:
427
+ states = {}
428
+ if isinstance(degraded, (str, bytes, Mapping)) or not isinstance(degraded, Iterable) or not isinstance(states, Mapping):
429
+ raise ServiceError("Connections declaration: degraded is a list of entries and states a mapping by id.")
430
+ failing = {entry.get("source") for entry in degraded if isinstance(entry, Mapping) and isinstance(entry.get("source"), str)}
431
+ connections: List[Any] = []
432
+ for item in declared:
433
+ if not isinstance(item, Mapping):
434
+ connections.append(item) # the shape check names it and the declaration is refused
435
+ continue
436
+ connection = dict(item)
437
+ if "state" not in connection:
438
+ ident = connection.get("id") if isinstance(connection.get("id"), str) else None
439
+ if ident is not None and ident in states:
440
+ connection["state"] = states[ident] # an invalid check value is refused by the shape check
441
+ else:
442
+ connection["state"] = "degraded" if ident in failing else "unknown"
443
+ connections.append(connection)
444
+ value = {"connections": connections}
445
+ view = connections_view({"extensions": {CONNECTIONS_EXTENSION_KEY: value}})
446
+ if view["reported"]:
447
+ raise ServiceError("Connections declaration: %s." % "; ".join(view["errors"]))
448
+ return {CONNECTIONS_EXTENSION_KEY: value}
@@ -35,6 +35,7 @@ from fabric_paths import (
35
35
  from fabric_protocols import (
36
36
  protocols_findings,
37
37
  )
38
+ from fabric_connections import CONNECTIONS_EXTENSION_KEY, connections_view # noqa: E402 - the kit (FAA-62)
38
39
  from fabric_usage import (
39
40
  ROUTES,
40
41
  )
@@ -570,6 +571,11 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
570
571
  problems = dashboard_extension_problems(extensions[DASHBOARD_EXTENSION_KEY])
571
572
  if problems:
572
573
  raise ServiceError("Dashboard declaration: %s." % "; ".join(problems))
574
+ if CONNECTIONS_EXTENSION_KEY in extensions:
575
+ # DEC-0096: a host hides an invalid declaration (FAC-SEM-127); a service never publishes one.
576
+ view = connections_view({"extensions": {CONNECTIONS_EXTENSION_KEY: extensions[CONNECTIONS_EXTENSION_KEY]}})
577
+ if view["reported"]:
578
+ raise ServiceError("Connections declaration: %s." % "; ".join(view["errors"]))
573
579
  doc["extensions"] = dict(extensions)
574
580
  if configuration is not None:
575
581
  problems = configuration_problems(configuration)