@passioncode-ai/fabric-agent-adapter 0.72.0 → 0.74.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 (40) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/README.md +4 -3
  3. package/SECURITY.md +17 -0
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +6 -2
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -16
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +2 -2
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -1
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +6 -2
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -1
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +4 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -1
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +10 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +36 -6
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +6 -4
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +19 -2
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +54 -3
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +841 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent_schema.py +496 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +38 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +53 -3
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -5
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +545 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +43 -7
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +29 -4
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +13 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +845 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +35 -5
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +212 -9
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/service-channel-agent-extension.schema.json +700 -0
  35. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  36. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +6 -2
  37. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +21 -8
  38. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/practices.md +15 -6
  39. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  40. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
@@ -0,0 +1,496 @@
1
+ """The schema half of the probe's channel-agent rule (FAA-197, DEC-0150 FAC-SEM-162): the vendored
2
+ `service-channel-agent-extension.schema.json` at the contract pin, a JSON Schema reader for exactly the keywords that
3
+ file uses, giving Ajv's errors in Ajv's order (allErrors, verbose), and the contract's reading of those errors as
4
+ places (`src/channel-agent.ts`: widths, oversize's names, placedToken, allowed, placed, messagePlaces, schemaErrors).
5
+
6
+ No place carries a declared value: a member name the schema does not declare is written `*` (CA-5). Not a kit module:
7
+ the probe has no Node twin and kit.json does not list it. Standard library only, Python 3.9+."""
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+ import re
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Any, Callable, Dict, List, Optional, Set
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
18
+ from fabric_jsvalue import double, is_number, js_keys, js_string, json_equal # noqa: E402
19
+
20
+ SCHEMA_FILE = Path(__file__).resolve().parent / "service-channel-agent-extension.schema.json"
21
+ #: FAC-SEM-162's one answer when the schema itself cannot run on a value.
22
+ TOO_LARGE = "the value is too large to check"
23
+ #: A string value or a member name longer than this, in code points, is refused before the schema runs (CA-2).
24
+ LONGEST_STRING = 4096
25
+ #: The arrays the schema declares: a numeric token after one of them is an index a path may carry.
26
+ ARRAYS = frozenset(("projects", "codes", "tiers", "channels", "sources", "declined_sources", "handoff_queues", "recorded_decisions"))
27
+ ENUM_VALUES = 8
28
+ PATTERN_CHARACTERS = 80
29
+ _INDEX = re.compile(r"(?:0|[1-9][0-9]{0,8})\Z")
30
+ _NONE_FORM_AT = re.compile(r"(?:^|/)projects/[0-9]+/(?:question_route|gap_registry|tickets/pd_check|channels/[0-9]+/texts/final)\Z")
31
+ _TEXTS_AT = re.compile(r"(?:^|/)channels/[0-9]+/texts(?:/holding)?\Z")
32
+ _CLOSED_TEXTS_AT = re.compile(r"/channels/[0-9]+/texts\Z")
33
+
34
+ # Ajv's keyword groups in the order its generated code runs them (ajv 8, draft 2020-12): the untyped group first, then
35
+ # one group per data type, each keyword in its vocabulary's order. Only what the vendored schema uses is read.
36
+ _GROUPS = (
37
+ (None, ("$ref", "const", "enum", "not", "oneOf", "if")),
38
+ ("number", ("maximum", "minimum")),
39
+ ("string", ("maxLength", "minLength", "pattern", "format")),
40
+ ("array", ("maxItems", "minItems", "items")),
41
+ ("object", ("maxProperties", "minProperties", "required", "propertyNames", "additionalProperties", "properties")),
42
+ )
43
+ _GROUP_KEYS = {group: keys for group, keys in _GROUPS if group is not None}
44
+ #: Members a schema node may carry that change nothing: metadata, and `then`/`else`, which `if` reads.
45
+ _INERT = frozenset(("$schema", "$id", "title", "description", "$defs", "then", "else", "type"))
46
+ SUPPORTED = frozenset(k for _, keys in _GROUPS for k in keys) | _INERT
47
+ #: What Ajv counts as a rule beside a `$ref` (a `$ref` with none is read as the reference alone).
48
+ _RULES = SUPPORTED - frozenset(("$schema", "$id", "title", "description", "$defs"))
49
+ _FORMATS = ("date",)
50
+
51
+ _schema: Optional[Dict[str, Any]] = None
52
+ _names: Optional[Set[str]] = None
53
+ _patterns: Dict[str, "re.Pattern[str]"] = {}
54
+
55
+
56
+ def schema() -> Dict[str, Any]:
57
+ """The vendored schema, read once."""
58
+ global _schema
59
+ if _schema is None:
60
+ _schema = json.loads(SCHEMA_FILE.read_text(encoding="utf-8"))
61
+ return _schema
62
+
63
+
64
+ def unsupported(node: Any = None) -> List[str]:
65
+ """The keywords and formats a schema node (default: the vendored schema) uses that this reader does not read."""
66
+ found: List[str] = []
67
+ stack = [schema() if node is None else node]
68
+ while stack:
69
+ item = stack.pop()
70
+ if isinstance(item, list):
71
+ stack.extend(item)
72
+ elif isinstance(item, dict):
73
+ for key, value in item.items():
74
+ if key not in SUPPORTED:
75
+ found.append(key)
76
+ elif key == "format" and value not in _FORMATS:
77
+ found.append("format %s" % value)
78
+ if key in ("properties", "$defs"):
79
+ stack.extend(value.values())
80
+ elif key not in ("enum", "const", "required", "pattern", "description", "title"):
81
+ stack.append(value)
82
+ return sorted(set(found))
83
+
84
+
85
+ def _pattern(text: str) -> "re.Pattern[str]":
86
+ """An ECMAScript pattern (u flag) as Python reads it: `$` outside a class is the end of input only (\\Z)."""
87
+ if text not in _patterns:
88
+ out, i, in_class = [], 0, False
89
+ while i < len(text):
90
+ char = text[i]
91
+ if char == "\\":
92
+ out.append(text[i:i + 2])
93
+ i += 2
94
+ continue
95
+ if char == "[":
96
+ in_class = True
97
+ elif char == "]":
98
+ in_class = False
99
+ out.append("\\Z" if char == "$" and not in_class else char)
100
+ i += 1
101
+ _patterns[text] = re.compile("".join(out))
102
+ return _patterns[text]
103
+
104
+
105
+ def _is(data: Any, kind: str) -> bool:
106
+ if kind == "object":
107
+ return isinstance(data, dict)
108
+ if kind == "array":
109
+ return isinstance(data, list)
110
+ if kind == "string":
111
+ return isinstance(data, str)
112
+ if kind == "boolean":
113
+ return isinstance(data, bool)
114
+ if kind == "null":
115
+ return data is None
116
+ if kind in ("number", "integer"):
117
+ if not is_number(data) or not math.isfinite(double(data)):
118
+ return False
119
+ return kind == "number" or double(data) % 1 == 0
120
+ return False
121
+
122
+
123
+ _DAYS = (0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
124
+
125
+
126
+ def _date(text: str) -> bool:
127
+ """ajv-formats' full `date`: YYYY-MM-DD with a real month and day, leap years counted."""
128
+ match = re.match(r"([0-9]{4})-([0-9]{2})-([0-9]{2})\Z", text)
129
+ if not match:
130
+ return False
131
+ year, month, day = (int(part) for part in match.groups())
132
+ leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
133
+ return 1 <= month <= 12 and 1 <= day <= (29 if month == 2 and leap else _DAYS[month])
134
+
135
+
136
+ def _pointer(key: str) -> str:
137
+ return key.replace("~", "~0").replace("/", "~1")
138
+
139
+
140
+ def _has_ref(node: Any) -> bool:
141
+ if isinstance(node, dict):
142
+ return "$ref" in node or any(_has_ref(value) for key, value in node.items() if key not in ("enum", "const"))
143
+ return isinstance(node, list) and any(_has_ref(item) for item in node)
144
+
145
+
146
+ class _Reader:
147
+ """Ajv's allErrors validation of one value, errors in its order with keyword, instancePath, schemaPath, params,
148
+ the keyword's schema (verbose) and, inside propertyNames, the member name. A definition that refers to no other is
149
+ inlined, so its errors' schemaPath starts `#/$defs/<name>`; one that does is its own function, whose paths start `#`."""
150
+
151
+ def __init__(self, root: Dict[str, Any]):
152
+ self.defs = root.get("$defs") or {}
153
+
154
+ def node(self, sch: Any, data: Any, ip: str, sp: str, errs: List[Dict[str, Any]], pn: Optional[str]) -> bool:
155
+ start = len(errs)
156
+ if sch is True:
157
+ return True
158
+ if sch is False or not isinstance(sch, dict):
159
+ errs.append(self.error("false schema", ip, sp, {}, False, pn))
160
+ return False
161
+ if "$ref" in sch and not any(key in _RULES for key in sch if key != "$ref"):
162
+ return self.ref(sch["$ref"], data, ip, errs, pn)
163
+ types = [sch["type"]] if isinstance(sch.get("type"), str) else list(sch.get("type") or [])
164
+ used = {group: any(key in sch for key in keys) for group, keys in _GROUP_KEYS.items()}
165
+ type_first = bool(types) and not (len(types) == 1 and used.get(types[0], False))
166
+ if type_first and not any(_is(data, kind) for kind in types):
167
+ errs.append(self.error("type", ip, sp + "/type", {"type": sch["type"]}, sch["type"], pn))
168
+ for group, keys in _GROUPS:
169
+ if group is not None and not used[group]:
170
+ continue
171
+ if group is not None and not _is(data, group):
172
+ if types == [group] and not type_first:
173
+ errs.append(self.error("type", ip, sp + "/type", {"type": sch["type"]}, sch["type"], pn))
174
+ continue
175
+ for key in keys:
176
+ if key in sch:
177
+ getattr(self, "k_" + key.lstrip("$"))(sch, sch[key], data, ip, sp, errs, pn)
178
+ return len(errs) == start
179
+
180
+ def error(self, keyword: str, ip: str, sp: str, params: Dict[str, Any], value: Any, pn: Optional[str]) -> Dict[str, Any]:
181
+ error = {"keyword": keyword, "instancePath": ip, "schemaPath": sp, "params": params, "schema": value}
182
+ if pn is not None:
183
+ error["propertyName"] = pn
184
+ return error
185
+
186
+ def ref(self, ref: str, data: Any, ip: str, errs: List[Dict[str, Any]], pn: Optional[str]) -> bool:
187
+ name = ref[len("#/$defs/"):]
188
+ target = self.defs[name]
189
+ return self.node(target, data, ip, "#" if _has_ref(target) else "#/$defs/" + name, errs, pn)
190
+
191
+ def k_ref(self, sch, value, data, ip, sp, errs, pn) -> None:
192
+ self.ref(value, data, ip, errs, pn)
193
+
194
+ def k_const(self, sch, value, data, ip, sp, errs, pn) -> None:
195
+ if not json_equal(data, value):
196
+ errs.append(self.error("const", ip, sp + "/const", {"allowedValue": value}, value, pn))
197
+
198
+ def k_enum(self, sch, value, data, ip, sp, errs, pn) -> None:
199
+ if not any(json_equal(data, item) for item in value):
200
+ errs.append(self.error("enum", ip, sp + "/enum", {"allowedValues": value}, value, pn))
201
+
202
+ def k_not(self, sch, value, data, ip, sp, errs, pn) -> None:
203
+ if self.node(value, data, ip, sp + "/not", [], pn):
204
+ errs.append(self.error("not", ip, sp + "/not", {}, value, pn))
205
+
206
+ def k_oneOf(self, sch, value, data, ip, sp, errs, pn) -> None:
207
+ start, passing = len(errs), []
208
+ for i, branch in enumerate(value):
209
+ if self.node(branch, data, ip, "%s/oneOf/%d" % (sp, i), errs, pn):
210
+ passing.append(i)
211
+ if len(passing) == 1:
212
+ del errs[start:]
213
+ else:
214
+ errs.append(self.error("oneOf", ip, sp + "/oneOf", {"passingSchemas": passing[:2] or None}, value, pn))
215
+
216
+ def k_if(self, sch, value, data, ip, sp, errs, pn) -> None:
217
+ branch = "then" if self.node(value, data, ip, sp + "/if", [], pn) else "else"
218
+ if branch in sch and not self.node(sch[branch], data, ip, "%s/%s" % (sp, branch), errs, pn):
219
+ errs.append(self.error("if", ip, sp + "/if", {"failingKeyword": branch}, value, pn))
220
+
221
+ def k_maximum(self, sch, value, data, ip, sp, errs, pn) -> None:
222
+ if double(data) > value:
223
+ errs.append(self.error("maximum", ip, sp + "/maximum", {"comparison": "<=", "limit": value}, value, pn))
224
+
225
+ def k_minimum(self, sch, value, data, ip, sp, errs, pn) -> None:
226
+ if double(data) < value:
227
+ errs.append(self.error("minimum", ip, sp + "/minimum", {"comparison": ">=", "limit": value}, value, pn))
228
+
229
+ def k_maxLength(self, sch, value, data, ip, sp, errs, pn) -> None:
230
+ if len(data) > value: # code points, as Ajv's ucs2length counts them
231
+ errs.append(self.error("maxLength", ip, sp + "/maxLength", {"limit": value}, value, pn))
232
+
233
+ def k_minLength(self, sch, value, data, ip, sp, errs, pn) -> None:
234
+ if len(data) < value:
235
+ errs.append(self.error("minLength", ip, sp + "/minLength", {"limit": value}, value, pn))
236
+
237
+ def k_pattern(self, sch, value, data, ip, sp, errs, pn) -> None:
238
+ if not _pattern(value).search(data):
239
+ errs.append(self.error("pattern", ip, sp + "/pattern", {"pattern": value}, value, pn))
240
+
241
+ def k_format(self, sch, value, data, ip, sp, errs, pn) -> None:
242
+ if value == "date" and not _date(data):
243
+ errs.append(self.error("format", ip, sp + "/format", {"format": value}, value, pn))
244
+
245
+ def k_maxItems(self, sch, value, data, ip, sp, errs, pn) -> None:
246
+ if len(data) > value:
247
+ errs.append(self.error("maxItems", ip, sp + "/maxItems", {"limit": value}, value, pn))
248
+
249
+ def k_minItems(self, sch, value, data, ip, sp, errs, pn) -> None:
250
+ if len(data) < value:
251
+ errs.append(self.error("minItems", ip, sp + "/minItems", {"limit": value}, value, pn))
252
+
253
+ def k_items(self, sch, value, data, ip, sp, errs, pn) -> None:
254
+ for i, item in enumerate(data):
255
+ self.node(value, item, "%s/%d" % (ip, i), sp + "/items", errs, pn)
256
+
257
+ def k_maxProperties(self, sch, value, data, ip, sp, errs, pn) -> None:
258
+ if len(data) > value:
259
+ errs.append(self.error("maxProperties", ip, sp + "/maxProperties", {"limit": value}, value, pn))
260
+
261
+ def k_minProperties(self, sch, value, data, ip, sp, errs, pn) -> None:
262
+ if len(data) < value:
263
+ errs.append(self.error("minProperties", ip, sp + "/minProperties", {"limit": value}, value, pn))
264
+
265
+ def k_required(self, sch, value, data, ip, sp, errs, pn) -> None:
266
+ for name in value:
267
+ if name not in data:
268
+ errs.append(self.error("required", ip, sp + "/required", {"missingProperty": name}, value, pn))
269
+
270
+ def k_propertyNames(self, sch, value, data, ip, sp, errs, pn) -> None:
271
+ for key in js_keys(data):
272
+ if not self.node(value, key, ip, sp + "/propertyNames", errs, key):
273
+ errs.append(self.error("propertyNames", ip, sp + "/propertyNames", {"propertyName": key}, value, pn))
274
+
275
+ def k_additionalProperties(self, sch, value, data, ip, sp, errs, pn) -> None:
276
+ declared = sch.get("properties") or {}
277
+ for key in js_keys(data):
278
+ if key in declared:
279
+ continue
280
+ if value is False:
281
+ errs.append(self.error("additionalProperties", ip, sp + "/additionalProperties", {"additionalProperty": key}, value, pn))
282
+ elif value is not True:
283
+ self.node(value, data[key], ip + "/" + _pointer(key), sp + "/additionalProperties", errs, pn)
284
+
285
+ def k_properties(self, sch, value, data, ip, sp, errs, pn) -> None:
286
+ for name, sub in value.items():
287
+ if name in data:
288
+ self.node(sub, data[name], ip + "/" + _pointer(name), "%s/properties/%s" % (sp, name), errs, pn)
289
+
290
+
291
+ def validate(value: Any) -> List[Dict[str, Any]]:
292
+ """The schema's errors on a value, as Ajv lists them; an empty list when it is valid."""
293
+ errs: List[Dict[str, Any]] = []
294
+ _Reader(schema()).node(schema(), value, "", "#", errs, None)
295
+ return errs
296
+
297
+
298
+ def schema_names() -> Set[str]:
299
+ """Every member name the schema declares: a path token that is not one (a text's language, an unknown member) is `*`."""
300
+ global _names
301
+ if _names is None:
302
+ names: Set[str] = set()
303
+ stack: List[Any] = [schema()]
304
+ while stack:
305
+ node = stack.pop()
306
+ if isinstance(node, list):
307
+ stack.extend(node)
308
+ elif isinstance(node, dict):
309
+ if isinstance(node.get("properties"), dict):
310
+ names.update(node["properties"])
311
+ stack.extend(node.values())
312
+ _names = names
313
+ return _names
314
+
315
+
316
+ def is_none(value: Any) -> bool:
317
+ """The standard's form for a required member a project does not have: an object with a `none` member (CA-2)."""
318
+ return isinstance(value, dict) and "none" in value
319
+
320
+
321
+ def placed_token(previous: Optional[str], before: Optional[str], token: str, index: bool, names: Set[str],
322
+ none_form: bool = False) -> str:
323
+ """A member's token in a FAC-SEM-162 place: a declared name, or an index of a declared array, else `*`."""
324
+ if none_form and before == "texts" and previous == "final" and token in ("none", "until"):
325
+ return token
326
+ if before == "texts" and previous in ("holding", "final"):
327
+ return "*"
328
+ if index:
329
+ return token if previous is not None and previous in ARRAYS and _INDEX.match(token) else "*"
330
+ return token if token in names else "*"
331
+
332
+
333
+ def resolved(node: Any, defs: Dict[str, Any], value: Any = None) -> Dict[str, Any]:
334
+ """A schema node with its `$ref` followed and a member that takes the none form resolved to the value's branch."""
335
+ at = node
336
+ while True:
337
+ if isinstance(at, dict) and isinstance(at.get("$ref"), str):
338
+ at = defs[at["$ref"][len("#/$defs/"):]]
339
+ elif isinstance(at, dict) and all(k in at for k in ("if", "then", "else")) and "properties" not in at:
340
+ at = at["then"] if is_none(value) else at["else"]
341
+ else:
342
+ return at if isinstance(at, dict) else {}
343
+
344
+
345
+ def widths(value: Any) -> List[str]:
346
+ """The places of every array past its maxItems, map past its maxProperties and closed object with more members than
347
+ it declares, read before the schema runs so it never makes one error per member (CA-2)."""
348
+ root = schema()
349
+ defs = root["$defs"]
350
+ found: List[str] = []
351
+ stack: List[Any] = [(value, root, "")]
352
+ while stack:
353
+ item, node, at = stack.pop()
354
+ rule = resolved(node, defs, item)
355
+ if isinstance(item, list):
356
+ if is_number(rule.get("maxItems")) and len(item) > rule["maxItems"]:
357
+ found.append("maxItems at %s (at most %s items)" % (at, js_string(rule["maxItems"])))
358
+ elif "items" in rule:
359
+ for i in range(len(item) - 1, -1, -1):
360
+ stack.append((item[i], rule["items"], "%s/%d" % (at, i)))
361
+ elif isinstance(item, dict):
362
+ count = len(item)
363
+ declared = rule.get("properties") if isinstance(rule.get("properties"), dict) else None
364
+ if declared is not None and rule.get("additionalProperties") is False and count > len(declared):
365
+ elsewhere = "; only texts.final takes the none form (Narrowings)" if _CLOSED_TEXTS_AT.search(at) and "none" in item else ""
366
+ found.append("maxProperties at %s (more members than the schema declares%s)" % (at or "/", elsewhere))
367
+ elif is_number(rule.get("maxProperties")) and count > rule["maxProperties"]:
368
+ found.append("maxProperties at %s (at most %s members)" % (at, js_string(rule["maxProperties"])))
369
+ elif declared is not None:
370
+ present = [name for name in declared if name in item]
371
+ for name in reversed(present):
372
+ stack.append((item[name], declared[name], "%s/%s" % (at, name)))
373
+ return found
374
+
375
+
376
+ def type_of(branch: Any) -> Optional[str]:
377
+ """A branch's type, through its `$ref`s into the schema's own `$defs`."""
378
+ if not isinstance(branch, dict):
379
+ return None
380
+ if isinstance(branch.get("type"), str):
381
+ return branch["type"]
382
+ if isinstance(branch.get("$ref"), str):
383
+ return type_of(schema()["$defs"].get(branch["$ref"][len("#/$defs/"):]))
384
+ return None
385
+
386
+
387
+ def name_rule(rule: Dict[str, Any]) -> str:
388
+ """What a `propertyNames` schema takes, from the schema alone: its pattern, its length, the name it refuses."""
389
+ parts = []
390
+ if isinstance(rule.get("pattern"), str):
391
+ parts.append("matching %s" % rule["pattern"])
392
+ if is_number(rule.get("maxLength")):
393
+ parts.append("at most %s code points" % js_string(rule["maxLength"]))
394
+ if isinstance(rule.get("not"), dict) and "const" in rule["not"]:
395
+ parts.append("other than %s" % js_string(rule["not"]["const"]))
396
+ return "a member name %s" % ", ".join(parts) if parts else "a member name the schema takes"
397
+
398
+
399
+ def allowed(error: Dict[str, Any], names: Set[str], at: str = "") -> str:
400
+ """What the schema allows at a failed place, from the schema alone, never the instance."""
401
+ params = error.get("params") or {}
402
+ keyword, rule = error["keyword"], error.get("schema")
403
+ texts = bool(_TEXTS_AT.search(at))
404
+ elsewhere = "; only texts.final takes the none form (Narrowings)" if texts and "none" in (params.get("additionalProperty"), params.get("propertyName")) else ""
405
+ if keyword == "required" and isinstance(params.get("missingProperty"), str) and params["missingProperty"] in names:
406
+ return " (%s)" % params["missingProperty"]
407
+ if keyword == "additionalProperties":
408
+ return " (only declared members are taken%s)" % elsewhere
409
+ if keyword == "propertyNames" and isinstance(rule, dict):
410
+ return " (%s%s)" % (name_rule(rule), elsewhere)
411
+ if keyword == "enum" and isinstance(params.get("allowedValues"), list):
412
+ values = [js_string(v) for v in params["allowedValues"]]
413
+ rest = " and %d more" % (len(values) - ENUM_VALUES) if len(values) > ENUM_VALUES else ""
414
+ return " (one of %s%s)" % (", ".join(values[:ENUM_VALUES]), rest)
415
+ if keyword == "const" and "allowedValue" in params:
416
+ return " (%s)" % js_string(params["allowedValue"])
417
+ if keyword == "pattern" and isinstance(params.get("pattern"), str):
418
+ pattern = params["pattern"]
419
+ return " (pattern %s)" % (pattern[:PATTERN_CHARACTERS] + "\u2026" if len(pattern) > PATTERN_CHARACTERS else pattern)
420
+ limit = params.get("limit") if is_number(params.get("limit")) else None
421
+ if limit is not None:
422
+ def count(word: str) -> str:
423
+ return "%s %s%s" % (js_string(limit), word, "" if limit == 1 else "s")
424
+ words = {"maxLength": "at most %s" % count("code point"), "minLength": "at least %s" % count("code point"),
425
+ "maxItems": "at most %s" % count("item"), "minItems": "at least %s" % count("item"),
426
+ "maxProperties": "at most %s" % count("member"), "minProperties": "at least %s" % count("member")}
427
+ if keyword in words:
428
+ return " (%s)" % words[keyword]
429
+ if keyword in ("minimum", "maximum"):
430
+ return " (%s %s)" % (js_string(params.get("comparison")), js_string(limit))
431
+ if keyword == "format" and isinstance(params.get("format"), str):
432
+ return " (format %s)" % params["format"]
433
+ if keyword == "not" and isinstance(rule, dict) and isinstance(rule.get("required"), list):
434
+ return " (%s only with not_yet)" % ", ".join(rule["required"])
435
+ if keyword == "not" and isinstance(rule, dict) and "const" in rule:
436
+ return " (a value other than %s)" % js_string(rule["const"])
437
+ if keyword == "type" and isinstance(params.get("type"), (str, list)):
438
+ form = ", or the none form {none: not_yet, until} / {none: not_used}" if _NONE_FORM_AT.search(at) else ""
439
+ kinds = params["type"] if isinstance(params["type"], list) else [params["type"]]
440
+ return " (%s%s)" % (" or ".join(kinds), form)
441
+ if keyword == "oneOf":
442
+ if isinstance(rule, list):
443
+ kinds = [type_of(branch) for branch in rule]
444
+ types = [kind for kind in kinds if kind is not None and kind != "null"]
445
+ if "null" in kinds and types:
446
+ return " (%s %s or null)" % ("an" if re.match(r"[aeiou]", types[0]) else "a", " or ".join(types))
447
+ return " (exactly one of its forms)"
448
+ return ""
449
+
450
+
451
+ def placed(error: Dict[str, Any], or_null: bool = False) -> str:
452
+ """An error as its keyword and its place, every free member name replaced by `*`, never a declared value."""
453
+ names = schema_names()
454
+ tokens = [t.replace("~1", "/").replace("~0", "~") for t in error["instancePath"].split("/")[1:]]
455
+ none_form = isinstance(error.get("schemaPath"), str) and not error["schemaPath"].startswith("#/$defs/texts")
456
+ at = "/".join(placed_token(tokens[i - 1] if i >= 1 else None, tokens[i - 2] if i >= 2 else None, token,
457
+ bool(_INDEX.match(token)), names, none_form) for i, token in enumerate(tokens))
458
+ return "%s at /%s%s%s" % (error["keyword"], at, allowed(error, names, at), ", or null" if or_null else "")
459
+
460
+
461
+ def message_places(places: List[str]) -> List[str]:
462
+ """The places a message names, each distinct pattern spelled once; a later one reads `(pattern as above)`."""
463
+ seen: Set[str] = set()
464
+ out = []
465
+ for written in places:
466
+ or_null = ", or null" if written.endswith(", or null") else ""
467
+ place = written[:len(written) - len(or_null)]
468
+ at = place.find(" (pattern ")
469
+ if at < 0:
470
+ out.append(written)
471
+ continue
472
+ pattern = place[at:]
473
+ if pattern in seen:
474
+ out.append("%s (pattern as above)%s" % (place[:at], or_null))
475
+ else:
476
+ seen.add(pattern)
477
+ out.append(written)
478
+ return out
479
+
480
+
481
+ def schema_errors(value: Any, validator: Callable[[Any], List[Dict[str, Any]]] = validate) -> List[str]:
482
+ """The schema's errors on a value as places: a nullable member fails in one line, its value branch's, ending
483
+ ", or null"; `if` lines and a member name's inner lines (its propertyNames line states them) are dropped."""
484
+ try:
485
+ errors = validator(value)
486
+ except RecursionError:
487
+ return [TOO_LARGE]
488
+ if not errors:
489
+ return []
490
+ nullable = {e["instancePath"] for e in errors
491
+ if e["keyword"] == "oneOf" and isinstance(e.get("schema"), list) and any(type_of(b) == "null" for b in e["schema"])}
492
+ kept = [e for e in errors if e["keyword"] != "if"
493
+ and not (e["keyword"] != "propertyNames" and e.get("propertyName") is not None)
494
+ and not (e["keyword"] == "type" and (e.get("params") or {}).get("type") == "null")
495
+ and not (e["keyword"] == "oneOf" and e["instancePath"] in nullable)]
496
+ return list(dict.fromkeys(placed(e, e["instancePath"] in nullable) for e in kept))
@@ -20,6 +20,44 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
20
20
  import fabric_service as fs # noqa: E402 - beside this file
21
21
  from fabric_http import redact_url # noqa: E402
22
22
  from fabric_secrets import ascii_decoded, repository_name, secret_argument # noqa: E402
23
+ import fabric_peer_owner as po # noqa: E402 - DEC-0154 HA-2
24
+ import socket # noqa: E402
25
+
26
+
27
+ # The rules that ask the service's port, named on one NOT_RUN line when none of them can be read (0.74.0 product
28
+ # reading 8): a probe names what it could not check.
29
+ PORT_RULES_UNREAD = ("every rule that reads the port (well-known, network, auth, usage, A2A, dashboard, login, "
30
+ "interop, channel-agent, board)")
31
+
32
+
33
+ class ForeignUser(ConnectionError):
34
+ """DEC-0154 HA-2: the server end of a local connection is not this OS account's process (`foreign-user`); nothing
35
+ was sent on it. A ConnectionError, so every rule that reads the port reports it as no answer."""
36
+
37
+
38
+ def listener_owner_verdict(port: int) -> Tuple[str, str]:
39
+ """`listener.owner` for a local service: (verdict, evidence). `down`, then `foreign-user`, then everything the
40
+ listener answers: a local service is read only when this OS account's process holds its port. The owner is the
41
+ server end of a connection of the probe's own, on which nothing is sent (HA-2's source: the client's own connection;
42
+ two sockets of one account on one port, as SO_REUSEPORT workers hold, read as that account)."""
43
+ where = "127.0.0.1:%d" % port
44
+ try:
45
+ sock = socket.create_connection(("127.0.0.1", port), timeout=5)
46
+ except ConnectionRefusedError:
47
+ return "NOT_RUN", "nothing listens on %s (down)" % where
48
+ except OSError as exc: # a full backlog, a timeout: the well-known rule reports what answers, each call checked
49
+ return "NOT_RUN", "could not connect to %s to tell its owner (%s)" % (where, type(exc).__name__)
50
+ try:
51
+ owner = po.server_end(sock)
52
+ finally:
53
+ sock.close()
54
+ if owner.kind == po.THIS_ACCOUNT:
55
+ return "PASS", "%s is held by this OS account's process (pid %s, read by %s)" % (
56
+ where, owner.pid if owner.pid is not None else "not shown", owner.method)
57
+ why = {po.ANOTHER_ACCOUNT: "another OS account's process", po.SYSTEM: "a system account's process"}.get(
58
+ owner.kind, "a process whose owner cannot be told (%s)" % owner.reason)
59
+ return "FAIL", ("foreign-user: %s is held by %s (read by %s); no credential was sent and nothing it answers is read "
60
+ "as this service's" % (where, why, owner.method))
23
61
 
24
62
  _LOCAL_PATH = re.compile(r"[/~.\\]|[A-Za-z]:[/\\]") # how a local path starts, never a user before an `@`
25
63
  _SCP_HOST = re.compile(r"[^@/:\\]+:") # what follows the `@` of user[:password]@host:path
@@ -9,12 +9,17 @@
9
9
  // is safe: an idempotent method or an `Idempotency-Key` header; any other request only on 429. The wait before retry n
10
10
  // (from 0) is min(maxBackoffS, backoffBaseS * 2**n) * (0.5 + rand()); a `Retry-After` replaces it up to
11
11
  // maxRetryAfterS. A wait past the deadline is not taken. Plain http is refused unless the host is loopback or
12
- // `insecure`. An error names the method and the redacted URL, never a header or a body.
12
+ // `insecure`. An error names the method and the redacted URL, never a header or a body. Before a byte goes to a plain
13
+ // http loopback URL the server end of the connection is told from the socket table (DEC-0154 HA-2, FAA-226): not this
14
+ // OS account's process, or an owner that cannot be told, is an HttpError of kind foreign-user, nothing sent, not retried.
13
15
  //
14
16
  // The transport is injected in tests: async ({ method, url, headers, body, timeoutS, maxBytes }) =>
15
17
  // { status, headers, body: Uint8Array }, throwing on a connection error (a `TimeoutError` name is a timeout) or an
16
18
  // HttpError. Node 20 or newer, no dependencies. Part of the service kit (FAA-65, layer L4).
17
19
 
20
+ import http from 'node:http';
21
+ import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs';
22
+
18
23
  export const RETRY_STATUSES = Object.freeze([408, 425, 429, 500, 502, 503, 504]);
19
24
  export const IDEMPOTENT_METHODS = Object.freeze(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
20
25
  export const DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
@@ -30,7 +35,7 @@ export class HttpError extends Error {
30
35
  this.attempts = attempts;
31
36
  this.retryAfterS = retryAfterS;
32
37
  this.body = body.slice(0, 2048);
33
- this.kind = kind; // http, connection, timeout, too-large, refused
38
+ this.kind = kind; // http, connection, timeout, too-large, refused, foreign-user
34
39
  }
35
40
  }
36
41
 
@@ -147,8 +152,53 @@ function checkHeaders(headers) {
147
152
  return out;
148
153
  }
149
154
 
150
- // The default transport: fetch, no redirects followed, the body read to one byte past maxBytes.
155
+ const LOOPBACK_AGENT = new CheckedAgent();
156
+
157
+ // Plain http to a loopback host (DEC-0154 HA-2): node:http over a connection checked before a byte is written, no
158
+ // redirect followed, the body read to one byte past maxBytes. fetch cannot hand its socket to the check.
159
+ export function checkedLoopbackTransport({ method, url, headers, body, timeoutS, maxBytes, agent = LOOPBACK_AGENT }) {
160
+ const parts = new URL(url);
161
+ // A body goes with its Content-Length, as fetch sent it: a server that reads only Content-Length (the kit's
162
+ // sample_service, the stdlib handlers) would read a chunked body as empty (0.74.0 seam reading 1).
163
+ const sent = { ...headers };
164
+ if (body && !Object.keys(sent).some((k) => k.toLowerCase() === 'content-length')) sent['Content-Length'] = String(body.length);
165
+ return new Promise((resolve, reject) => {
166
+ const req = http.request({ host: parts.hostname.replace(/^\[|\]$/g, ''), port: Number(parts.port || 80),
167
+ path: parts.pathname + parts.search, method, headers: sent, agent }, (res) => {
168
+ const chunks = [];
169
+ let size = 0;
170
+ res.on('data', (c) => {
171
+ if (size <= maxBytes) { chunks.push(c); size += c.length; }
172
+ if (size > maxBytes) res.destroy();
173
+ });
174
+ res.on('close', () => {
175
+ clearTimeout(timer);
176
+ const all = Buffer.concat(chunks);
177
+ const got = Object.fromEntries(Object.entries(res.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : String(v)]));
178
+ if (!res.complete && size <= maxBytes) reject(Object.assign(new Error('the answer broke off'), { name: 'ConnectionError' }));
179
+ else resolve({ status: res.statusCode, headers: got, body: new Uint8Array(all.subarray(0, maxBytes + 1)) });
180
+ });
181
+ });
182
+ const timer = setTimeout(() => req.destroy(Object.assign(new Error('timed out'), { name: 'TimeoutError' })),
183
+ Math.max(1, Math.ceil(timeoutS * 1000)));
184
+ req.on('error', (err) => {
185
+ clearTimeout(timer);
186
+ if (err instanceof ListenerRefused) {
187
+ reject(new HttpError(`${method} ${redactUrl(url)}: foreign-user: ${err.message}`, { url: redactUrl(url), kind: 'foreign-user' }));
188
+ } else reject(err);
189
+ });
190
+ if (body) req.write(body);
191
+ req.end();
192
+ });
193
+ }
194
+
195
+ // The default transport: fetch, no redirects followed, the body read to one byte past maxBytes; plain http to a
196
+ // loopback host goes through checkedLoopbackTransport.
151
197
  export async function fetchTransport({ method, url, headers, body, timeoutS, maxBytes }) {
198
+ const target = new URL(url);
199
+ if (target.protocol === 'http:' && isLoopback(target.hostname)) {
200
+ return checkedLoopbackTransport({ method, url, headers, body, timeoutS, maxBytes });
201
+ }
152
202
  const res = await fetch(url, { method, headers, body: body ?? undefined, redirect: 'manual',
153
203
  signal: AbortSignal.timeout(Math.max(1, Math.ceil(timeoutS * 1000))) });
154
204
  const chunks = [];