@passioncode-ai/fabric-agent-adapter 0.81.0 → 0.83.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 (127) hide show
  1. package/CHANGELOG.md +200 -3
  2. package/README.md +11 -6
  3. package/SECURITY.md +8 -5
  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 +7 -2
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +9 -1
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +25 -2
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +19 -1
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +274 -17
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +24 -151
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
  17. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +3 -16
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +17 -9
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +24 -6
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +14 -4
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +23 -10
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +9 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -2
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +28 -4
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +30 -11
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +19 -1
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +4 -2
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +7 -5
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +22 -11
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +29 -7
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +5 -2
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +27 -10
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +21 -8
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +88 -5
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +133 -30
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +44 -5
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +35 -159
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +43 -11
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +21 -18
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +39 -17
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +92 -10
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +180 -162
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +274 -17
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +465 -55
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
  106. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
  107. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
  111. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
  112. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +9 -1
  113. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
  114. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
  115. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
  116. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
  117. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +19 -1
  118. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +30 -17
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +53 -35
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
  124. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
  125. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +34 -21
  126. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +96 -15
  127. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
@@ -3,7 +3,6 @@ accept the exact digest, effect, progress, complete; stop when fenced). The sess
3
3
  in fabric_board_link and are re-exported here. Part of the board connector (FAA-65, layer L4)."""
4
4
  from __future__ import annotations
5
5
 
6
- import inspect
7
6
  import json
8
7
  import random
9
8
  import re
@@ -28,6 +27,8 @@ from fabric_board_link import ( # noqa: E402,F401 - re-exported: callers import
28
27
  BoardSession,
29
28
  Checkpoint,
30
29
  PERSONAL_BOARD,
30
+ notice_subject as _notice_subject,
31
+ takes_subject as _takes_subject,
31
32
  )
32
33
  from fabric_board_slots import ( # noqa: E402,F401 - the slot records (FAA-155, FAA-163); the constants re-exported
33
34
  HOLD_RETRY_MAX_SECONDS,
@@ -51,6 +52,9 @@ IDLE_LEAVE_SECONDS = 3.0
51
52
 
52
53
  EVENT_SCHEMA = "urn:fabric:schema:board-responder-event:1"
53
54
 
55
+ #: `board.down` is said once per outage window: said again only after the board stayed up this long (FAA-187 (22)).
56
+ DOWN_QUIET_SECONDS = 300.0
57
+
54
58
  #: On these the attempt is no longer ours: stop acting at once (C4).
55
59
  STOP_CODES = frozenset(["fenced", "lease_expired", "authority_changed"])
56
60
 
@@ -60,8 +64,6 @@ TERMINAL = frozenset(["completed", "failed_known", "cancelled", "expired"])
60
64
  NOT_OURS = frozenset(["queued", "claimed", "answered"])
61
65
 
62
66
 
63
-
64
-
65
67
  class WorkContext:
66
68
  """What a handler may do while it works: `progress(note)` posts a note on the request's thread (and renews the
67
69
  lease); `begin_effect(summary)` must be called (and must return) before the handler spends or acts outside
@@ -70,12 +72,12 @@ class WorkContext:
70
72
  `mandate_covers(call)` whether it covers a call (`mandate_covers`' answer), None when none travelled."""
71
73
 
72
74
  def __init__(self, responder: "Responder", entry: Dict[str, Any], mandate: Optional[Dict[str, Any]] = None,
73
- caller: Optional[str] = None):
74
- self._responder, self._entry, self.mandate, self._caller = responder, entry, mandate, caller
75
+ caller: Optional[str] = None, caller_agent: Optional[str] = None):
76
+ self._responder, self._entry, self.mandate, self._caller, self._agent = responder, entry, mandate, caller, caller_agent
75
77
  self.stopped = threading.Event()
76
78
 
77
79
  def mandate_covers(self, call: Dict[str, Any]) -> Optional[Dict[str, Any]]:
78
- return _link.covers(self.mandate, call, self._responder.agent_id, self._responder.wall, self._caller)
80
+ return _link.covers(self.mandate, call, self._responder.agent_id, self._responder.wall, self._caller, self._agent)
79
81
 
80
82
  def progress(self, note: Optional[str] = None) -> None:
81
83
  if self.stopped.is_set():
@@ -100,33 +102,6 @@ class _Work:
100
102
  Handler = Callable[[BoardRequest, WorkContext], Dict[str, Any]]
101
103
 
102
104
 
103
-
104
- def _notice_subject(value: Dict[str, Any], project: str, episode: Optional[str] = None) -> Dict[str, str]:
105
- """A notice's subject (`{type, id}`): its request, else its capability, else its episode (a cause such as a block's
106
- code, so two causes are two episodes for a host, seam r31), else the Project."""
107
- for kind, given in (("request", value.get("request")), ("capability", value.get("capability")), ("episode", episode)):
108
- if isinstance(given, str) and 0 < len(given) <= 128:
109
- return {"type": kind, "id": given}
110
- return {"type": "project", "id": project}
111
-
112
-
113
- def _takes_subject(emit: Callable[..., Any]) -> bool:
114
- """Whether an emit's signature names a `subject` parameter, as JsonlEventLog.append does. Any other emit (one with
115
- only **kwargs, which may forward them to a sink without it or set its own, or one whose signature cannot be read)
116
- is called as 0.55.0 called it, without one (0.56.0 unit r33). A subject already bound into the emit, as with
117
- functools.partial(log.append, subject=...), stands.
118
-
119
- The signature is the emit's own, not a wrapped function's: a functools.wraps forwarder that sets its own subject
120
- would otherwise be passed a second one (0.56.0 unit r34). Nothing here raises, so a notice never stops the
121
- responder."""
122
- try:
123
- params = inspect.signature(emit, follow_wrapped=False).parameters.values()
124
- return any(p.name == "subject" and p.kind is not inspect.Parameter.VAR_KEYWORD
125
- and (p.default is inspect.Parameter.empty or p.default is None) for p in params)
126
- except Exception: # noqa: BLE001 - an unreadable signature (or a default that raises) is called without one
127
- return False
128
-
129
-
130
105
  class Responder(SlotKeeper):
131
106
  """One service's responder for one Project and one or more capabilities, one request at a time.
132
107
 
@@ -142,9 +117,11 @@ class Responder(SlotKeeper):
142
117
  emit: Optional[Callable[..., Any]] = None, service: str = "service",
143
118
  now: Callable[[], float] = time.monotonic, rand: Callable[[], float] = random.random,
144
119
  spawn: Optional[Callable[[Callable[[], None]], Any]] = None, session_factory: Optional[Callable[..., BoardSession]] = None,
145
- agent_id: Optional[str] = None, mandate_from: Any = None, wall: Callable[[], float] = time.time):
146
- if admit is None or handler is None:
147
- raise BoardError("invalid_arguments", "A responder needs the service's own input gate (admit) and a handler.")
120
+ agent_id: Optional[str] = None, mandate_from: Any = None, wall: Callable[[], float] = time.time,
121
+ identify_agent: Optional[Callable[[Dict[str, Any]], Optional[str]]] = None, links: Any = None):
122
+ if admit is None or handler is None or not (identify_agent is None or callable(identify_agent)):
123
+ raise BoardError("invalid_arguments", "A responder needs the service's own input gate (admit) and a handler; "
124
+ "identify_agent, when given, is a function of the sender (FAA-135).")
148
125
  caps = list(capabilities)
149
126
  if not caps or any(not isinstance(c, str) or not _CAPABILITY.fullmatch(c) for c in caps) or len(set(caps)) != len(caps):
150
127
  raise BoardError("invalid_arguments", "Name each capability once, [a-z][a-z0-9._-]{1,127}.")
@@ -152,8 +129,10 @@ class Responder(SlotKeeper):
152
129
  raise BoardError("invalid_arguments", "%s is a Project's primary responder's, a session's: a service answers "
153
130
  "the capabilities it declares (DEC-0140 PR-1)." % _fs.PRIMARY_CAPABILITY)
154
131
  self.hub, self.project = hub, _hub._project(project_id)
155
- #: FAA-135: read the operator's mandate from a trusted request of his own Project (opt-in; None reads none).
132
+ #: FAA-135: read the operator's mandate from a trusted request of his own Project (opt-in; None reads none), and
133
+ #: bind its chain with the agent id identify_agent names for the sender (asked only when a mandate travelled).
156
134
  self.mandate_from, self.agent_id, self.wall = _link.mandate_options(agent_id, mandate_from), agent_id, wall
135
+ self.identify_agent, self.links = identify_agent, _link.notice_links(links) # FAA-187 (21): a link per notify kind
157
136
  self.capabilities = caps
158
137
  self.credential_file, self.handler, self.admit, self.observe = Path(credential_file), handler, admit, observe
159
138
  self.checkpoint = Checkpoint(Path(checkpoint_file))
@@ -172,6 +151,7 @@ class Responder(SlotKeeper):
172
151
  self.claim_keys: Dict[str, str] = {}
173
152
  self._probing: set = set() # moved slots whose move-back claim lost its answer: asked again under the same key
174
153
  self._said: Dict[str, str] = {}
154
+ self._up_since: Optional[float] = None # when the board was last seen up after a fault (FAA-187 (22))
175
155
  self._rr = 0
176
156
  #: The first pause under an operator's hold (DEC-0079); it doubles to HOLD_RETRY_MAX_SECONDS.
177
157
  self.hold_delay = POLL_SECONDS
@@ -203,6 +183,8 @@ class Responder(SlotKeeper):
203
183
  # goes once, without a subject there (unit r31, seam r31).
204
184
  if _takes_subject(self._emit_to):
205
185
  extra["subject"] = _notice_subject(value, self.project, episode)
186
+ if notify and kind in getattr(self, "links", {}): # the page that resolves it, which the service named (FAA-187 (21))
187
+ extra["link"] = self.links[kind]
206
188
  try:
207
189
  self._emit_to(kind, level, text, notify=notify, **extra)
208
190
  except Exception: # noqa: BLE001 - an event that cannot be written never stops the responder
@@ -267,8 +249,8 @@ class Responder(SlotKeeper):
267
249
  self.slots = {}
268
250
  self._recover(status, [c for c in self.capabilities if c in grant.capabilities])
269
251
  self.state = "ready"
270
- for kind in ("board.down", "board.blocked") + (() if missing else ("board.access.needed",)):
271
- self._said.pop(kind, None)
252
+ for kind in ("board.blocked",) + (() if missing else ("board.access.needed",)):
253
+ self._said.pop(kind, None) # board.down waits for its quiet window (step)
272
254
 
273
255
  def _enroll(self, capability: str) -> None:
274
256
  args = {"idempotency": self._idem(self._key("enroll")), "capability": capability}
@@ -525,6 +507,8 @@ class Responder(SlotKeeper):
525
507
  self.backoff.reset()
526
508
  if self.state == "down": # a lost call was sent again and answered: the session held throughout
527
509
  self.state = "ready"
510
+ self._up_since = now if self._up_since is None else self._up_since
511
+ if now - self._up_since >= DOWN_QUIET_SECONDS: # up a whole window: a later outage is said again
528
512
  self._said.pop("board.down", None)
529
513
  return self.interval()
530
514
  except (BoardError, _hub.HubError) as exc:
@@ -542,7 +526,7 @@ class Responder(SlotKeeper):
542
526
  return min(renewed) + LEASE_SECONDS - self.now() if renewed and self.session is not None else 0.0
543
527
 
544
528
  def _fault(self, exc: Exception) -> float:
545
- code, slots = getattr(exc, "code", "unreachable"), self.slots.values()
529
+ code, slots, self._up_since = getattr(exc, "code", "unreachable"), self.slots.values(), None
546
530
  connecting = self.state == "connecting" and not any(s.get("state") == "held" for s in slots)
547
531
  wait = self.backoff.next()
548
532
  left = self._lease_left() - LEASE_MARGIN_SECONDS
@@ -613,10 +597,11 @@ class Responder(SlotKeeper):
613
597
  for capability in list(self._suspect): # kept until com.status answered, so it is asked before any enroll
614
598
  self._gone(capability)
615
599
  self._suspect.discard(capability)
600
+ shared: Dict[str, Any] = {} # one com.status read a tick for every moved slot (FAA-194 (5))
616
601
  for capability, slot in list(self.slots.items()):
617
602
  if slot.get("state") == "moved": # never enrolled again on its own, even when the new holder lapses (FAA-155)
618
603
  if self.now() - slot.get("renewed_at", 0) >= RENEW_SECONDS - 0.5:
619
- self._moved_back(capability) # unless com.status shows a later generation it may hold (FAA-165)
604
+ self._moved_back(capability, shared) # unless com.status shows a later generation it may hold (FAA-165)
620
605
  continue
621
606
  if slot.get("state") == "paused":
622
607
  if capability != held_by_work and self.now() >= slot.get("retry_at", 0.0) and not self._gone(capability):
@@ -699,15 +684,14 @@ class Responder(SlotKeeper):
699
684
  "revision": receipt["request"]["revision"], "step": "claimed", "keys": {}, "effect": False}
700
685
  self.checkpoint.put(entry)
701
686
  message = self._call("com.get", {"message": entry["message"]})
702
- req = message.get("request") if isinstance(message, dict) else None
703
- if message.get("digest") != entry["digest"]:
687
+ problem = _link.read_back_problem(message, entry, self.project)
688
+ if problem == "digest-mismatch":
704
689
  self.checkpoint.drop(entry["request"])
705
690
  self._emit("board.request.skipped", "warning", "A claimed request was not accepted: the message read back carries "
706
691
  "another digest than the claim. Nothing was done; the claim lapses.", request=entry["request"],
707
692
  capability=capability, reason="digest-mismatch")
708
693
  return
709
- if not isinstance(req, dict) or req.get("id") != entry["request"] or req.get("capability") != capability \
710
- or req.get("target") != self.project:
694
+ if problem:
711
695
  self.checkpoint.drop(entry["request"])
712
696
  self._emit("board.request.skipped", "warning", "A claimed request was not accepted: it is not addressed to this "
713
697
  "Project and capability. Nothing was done; the claim lapses.", request=entry["request"],
@@ -754,12 +738,15 @@ class Responder(SlotKeeper):
754
738
  self.checkpoint.put(entry)
755
739
  return True
756
740
 
757
- def _run(self, entry: Dict[str, Any], request: BoardRequest) -> None:
741
+ def _run(self, entry: Dict[str, Any], request: BoardRequest, problem: Optional[str] = None) -> None:
758
742
  mandate, unread = _link.read_mandate(request, self.mandate_from, self.agent_id)
743
+ if problem and (mandate is not None or unread): # FAA-196 (1): a resume read back another message than accepted
744
+ mandate, unread = None, problem
759
745
  if unread: # by its code alone: the block holds the operator's words
760
746
  self._emit("board.request.mandate_unread", "warning", "Request %s carries a mandate the kit does not read (%s); "
761
747
  "it is worked without one." % (entry["request"], unread), request=entry["request"], reason=unread)
762
- ctx = WorkContext(self, entry, mandate, request.sender.get("project") if isinstance(request.sender, dict) else None)
748
+ sender = request.sender if isinstance(request.sender, dict) else {}
749
+ ctx = WorkContext(self, entry, mandate, sender.get("project"), _link.sender_agent(self.identify_agent, sender, mandate))
763
750
  work = _Work(entry, request, ctx)
764
751
  work.renewed_at = self.now()
765
752
  self.current = work
@@ -781,7 +768,7 @@ class Responder(SlotKeeper):
781
768
  self._complete(entry)
782
769
  return
783
770
  message = self._call("com.get", {"message": entry["message"]})
784
- self._run(entry, BoardRequest(message, entry["capability"]))
771
+ self._run(entry, BoardRequest(message, entry["capability"]), _link.read_back_problem(message, entry, self.project))
785
772
 
786
773
  def _fenced(self, op: str, entry: Dict[str, Any], key: str, extra: Optional[Dict[str, Any]] = None,
787
774
  apply: Optional[Callable[[Dict[str, Any]], None]] = None) -> Optional[Dict[str, Any]]:
@@ -125,19 +125,20 @@ class SlotKeeper:
125
125
  self._note(capability, dict(held, moved=now)) # said again at the next start if it cannot be written
126
126
  if "moved" not in held: # once, even across restarts
127
127
  self._emit("board.slot.moved", "warning", "The %s slot on this Project moved to another session. This service stopped answering it; accepted "
128
- "work stays `accepted` (work begun is the new holder's). The operator brings it back by take-back (once the "
129
- "other session lets it go, or at a restart with accepted work waiting; a restart alone does not) or, where "
130
- "Fabric offers it, a move back here. Else ask its maintainer." % capability,
128
+ "work stays accepted (work begun is the new holder's). The operator brings it back by take-back (once the "
129
+ "other session lets it go, or at a restart or reconnect with accepted work waiting; neither alone does) or, "
130
+ "where Fabric can, a move back here. Else ask its maintainer." % capability,
131
131
  notify=True, episode=capability, capability=capability, generation=now, held=held["generation"])
132
132
  return True
133
133
 
134
- def _moved_back(self, capability: str) -> None:
134
+ def _moved_back(self, capability: str, shared: Optional[Dict[str, Any]] = None) -> None:
135
135
  """A moved slot asked about once per later generation (FAA-165): the operator may move it back to this session
136
136
  (DEC-0140 PR-2), which this client cannot hear, so when com.status shows a generation past the recorded move, one
137
137
  `com.claim` asks whether the slot is its own. A claim that lands makes it held again and starts what it handed
138
138
  out; a `lease_expired` one (the move reached this session but its lease lapsed) enrolls again; a fenced one is
139
139
  another session's, recorded so the same generation is not asked about twice. Never an enroll or a replace on a
140
- slot another session holds: a slot moved off stays off unless it is handed back."""
140
+ slot another session holds: a slot moved off stays off unless it is handed back. `shared` holds the tick's one
141
+ com.status read, shared by every moved slot (FAA-194 (5))."""
141
142
  if self.current is not None or self.stopping:
142
143
  return # a claim now could hand out a second request, or start work while the responder stops
143
144
  held = self.checkpoint.slots.get(capability) or {}
@@ -145,7 +146,12 @@ class SlotKeeper:
145
146
  if held.get("project") != self.project or not _is_int(seen, 1):
146
147
  return
147
148
  (self.slots.get(capability) or {})["renewed_at"] = self.now()
148
- status = self._status()
149
+ if shared is not None and "status" in shared:
150
+ status = shared["status"]
151
+ else:
152
+ status = self._status()
153
+ if shared is not None:
154
+ shared["status"] = status
149
155
  now = _whole(next((r.get("generation") for r in status.get("responders") or [] if isinstance(r, dict) and r.get("capability") == capability), None))
150
156
  if not _is_int(now, 1) or now <= seen:
151
157
  return
@@ -202,7 +208,9 @@ class SlotKeeper:
202
208
  self.slots[capability] = {"state": "held", "generation": generation, "renewed_at": float("-inf")} # no lease yet
203
209
  self._held_at(capability, generation)
204
210
  self._said.pop("board.slot.moved", None) # a later move is said again
205
- return True
211
+ self._emit("board.slot.returned", "info", "The %s slot on this Project is this service's again: the operator moved it "
212
+ "back here, and it answers it from now on." % capability, capability=capability, generation=generation)
213
+ return True # FAA-194 (8): the feed no longer ends on the move's warning
206
214
 
207
215
  def take_back(self, capability: str) -> None:
208
216
  """The operator's act, never the kit's: forget the move; the next tick enrolls once the slot is free (FAA-155)."""
@@ -4,12 +4,9 @@
4
4
  `degraded[]`. Part of the service kit (FAA-62, layer L3). Standard library only."""
5
5
  from __future__ import annotations
6
6
 
7
- import bisect
8
- import ipaddress
9
7
  import re
10
- import unicodedata
11
8
  import urllib.parse
12
- from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
9
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
13
10
 
14
11
  import sys as _sys # noqa: E402
15
12
  from pathlib import Path as _Path # noqa: E402
@@ -18,7 +15,12 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
18
15
 
19
16
  from fabric_base import ServiceError # noqa: E402
20
17
  from fabric_secrets import ( # noqa: E402
21
- JWT_SHAPE, _BEARER_INSIDE as _BEARER, _long_secret, credential_name, raw_values, secret_argument, secret_value,
18
+ _BEARER_INSIDE as _BEARER, _long_secret, credential_name, has_jwt, raw_values, secret_argument, secret_value,
19
+ )
20
+ # The WHATWG host reading and its IDNA tables (FAA-98) live in fabric_secrets since FAA-309, where secret_url reads a
21
+ # URL's host with them; the names stay here for code and tests that reached them.
22
+ from fabric_secrets import ( # noqa: E402,F401
23
+ _FORBIDDEN_DOMAIN, _FORBIDDEN_HOST, _IDNA_DISALLOWED, _IDNA_IGNORED, _domain_ok, _idna_host, _ipv4_number, _ipv6_ok,
22
24
  )
23
25
 
24
26
  CONNECTIONS_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/connections/0.1"
@@ -50,7 +52,8 @@ _KEY_SHAPE = re.compile(
50
52
  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,})"
51
53
  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,}"
52
54
  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_-]))")
53
- _URL_SCHEME = re.compile(r"[a-z][a-z0-9+.-]*://", re.IGNORECASE | re.ASCII)
55
+ _SCHEME_LETTERS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
56
+ _SCHEME_CHARS = _SCHEME_LETTERS | frozenset("0123456789+.-")
54
57
  _CLOSING = ")]}>\"'.,;:!?"
55
58
  _TRIED_BACK = 1
56
59
 
@@ -137,13 +140,33 @@ def _shape_problems(value: Any) -> List[str]:
137
140
  return problems
138
141
 
139
142
 
143
+ def _scheme_spans(piece: str) -> List[Tuple[int, int]]:
144
+ """The (start, end) of each URL scheme and its `://` in a piece, where `[a-z][a-z0-9+.-]*://` (case-insensitive,
145
+ ASCII) finds them left to right, read in linear time (FAA-322: the pattern tried every letter of a long run with no
146
+ `://`). From each `://` the run of scheme characters before it is read back, and the match starts at its first
147
+ letter at or after where the last match ended."""
148
+ spans: List[Tuple[int, int]] = []
149
+ after, at = 0, piece.find("://")
150
+ while at >= 0:
151
+ start = at
152
+ while start > after and piece[start - 1] in _SCHEME_CHARS:
153
+ start -= 1
154
+ while start < at and piece[start] not in _SCHEME_LETTERS:
155
+ start += 1
156
+ if start < at:
157
+ spans.append((start, at + 3))
158
+ after = at + 3
159
+ at = piece.find("://", at + 1)
160
+ return spans
161
+
162
+
140
163
  def _urls_in(value: str) -> List[str]:
141
164
  """The URLs a member may hold, from every scheme in each whitespace-delimited piece, without the closing brackets,
142
165
  quotes and punctuation that end a sentence, and with _TRIED_BACK of them put back (`]` closes an IPv6 host)."""
143
166
  urls: List[str] = []
144
167
  for piece in _SPACES.split(value):
145
- for match in _URL_SCHEME.finditer(piece):
146
- url = piece[match.start():]
168
+ for start, _end in _scheme_spans(piece):
169
+ url = piece[start:]
147
170
  end = len(url)
148
171
  while end > 0 and url[end - 1] in _CLOSING:
149
172
  end -= 1
@@ -158,8 +181,8 @@ def _long_run(value: str) -> bool:
158
181
  """A long secret-shaped run in a piece read whole; in a piece that holds a URL, the text before its first scheme's
159
182
  `://` (the URL itself is read by secret_url)."""
160
183
  for piece in _SPACES.split(value):
161
- found = _URL_SCHEME.search(piece)
162
- cut = piece.find("://", found.start()) if found else -1
184
+ found = _scheme_spans(piece)
185
+ cut = found[0][1] - 3 if found else -1
163
186
  if _long_secret(piece if cut < 0 else piece[:cut]):
164
187
  return True
165
188
  return False
@@ -171,153 +194,6 @@ _credential_name, _secret_value, _secret_argument = credential_name, secret_valu
171
194
 
172
195
 
173
196
  _SPECIAL = ("http", "https", "ws", "wss", "ftp", "file")
174
- _FORBIDDEN_HOST = set("\x00\t\n\r #/:<>?@[\\]^|")
175
- _FORBIDDEN_DOMAIN = _FORBIDDEN_HOST | set(chr(c) for c in range(0x20)) | {"%", "\x7f"}
176
-
177
-
178
- # FAA-98: UTS46 narrowed, not ported. A non-ASCII host is refused only where every Node from 20 to 26 refuses it, so
179
- # the doubt stays on the closed side (its URL's credentials are read). The two tables are measured by
180
- # test/fixtures/idna/make_idna_tables.mjs (idna-tables.json, held equal by test_the_idna_tables_are_the_measured_ones):
181
- # code points refused in every context, and code points removed between letters. The contextual rules read Unicode 3.2
182
- # classes (`unicodedata.ucd_3_2_0`) and skip what that Unicode does not know, since Node's own UTS46 data lags its
183
- # Unicode: an Arabic letter of Unicode 14 is no right-to-left letter to it.
184
- _IDNA_DISALLOWED = (
185
- (0x80, 0xa0), (0xa8, 0xa8), (0xaf, 0xaf), (0xb4, 0xb4), (0xb8, 0xb8), (0x2d8, 0x2dd), (0x37a, 0x37a),
186
- (0x384, 0x385), (0x600, 0x605), (0x61c, 0x61c), (0x6dd, 0x6dd), (0x70f, 0x70f), (0x890, 0x891), (0x8e2, 0x8e2),
187
- (0x1680, 0x1680), (0x1fbd, 0x1fbd), (0x1fbf, 0x1fc1), (0x1fcd, 0x1fcf), (0x1fdd, 0x1fdf), (0x1fed, 0x1fee),
188
- (0x1ffd, 0x1ffe), (0x2000, 0x200a), (0x200e, 0x200f), (0x2017, 0x2017), (0x2024, 0x2026), (0x2028, 0x202f),
189
- (0x203e, 0x203e), (0x2047, 0x2049), (0x205f, 0x205f), (0x2066, 0x2069), (0x2100, 0x2101), (0x2105, 0x2106),
190
- (0x2488, 0x249b), (0x2a74, 0x2a74), (0x2ff0, 0x3000), (0x309b, 0x309c), (0x31ef, 0x31ef), (0x33c2, 0x33c2),
191
- (0x33c7, 0x33c7), (0x33d8, 0x33d8), (0xe000, 0xf8ff), (0xfc5e, 0xfc63), (0xfdfa, 0xfdfb), (0xfe12, 0xfe13),
192
- (0xfe16, 0xfe16), (0xfe19, 0xfe19), (0xfe30, 0xfe30), (0xfe47, 0xfe4c), (0xfe52, 0xfe52), (0xfe55, 0xfe56),
193
- (0xfe5f, 0xfe5f), (0xfe64, 0xfe65), (0xfe68, 0xfe68), (0xfe6a, 0xfe6b), (0xfe70, 0xfe70), (0xfe72, 0xfe72),
194
- (0xfe74, 0xfe74), (0xfe76, 0xfe76), (0xfe78, 0xfe78), (0xfe7a, 0xfe7a), (0xfe7c, 0xfe7c), (0xfe7e, 0xfe7e),
195
- (0xff03, 0xff03), (0xff05, 0xff05), (0xff0f, 0xff0f), (0xff1a, 0xff1a), (0xff1c, 0xff1c), (0xff1e, 0xff20),
196
- (0xff3b, 0xff3e), (0xff5c, 0xff5c), (0xffe3, 0xffe3), (0xfff9, 0xfffd), (0x110bd, 0x110bd), (0x110cd, 0x110cd),
197
- (0x13430, 0x1343f), (0x1f100, 0x1f100), (0xe0001, 0xe0001), (0xe0020, 0xe007f), (0xf0000, 0xffffd),
198
- (0x100000, 0x10fffd),
199
- )
200
- _IDNA_IGNORED = (
201
- (0xad, 0xad), (0x34f, 0x34f), (0x180b, 0x180d), (0x180f, 0x180f), (0x200b, 0x200b), (0x2060, 0x2060),
202
- (0x2064, 0x2064), (0xfe00, 0xfe0f), (0xfeff, 0xfeff), (0x1bca0, 0x1bca3), (0xe0100, 0xe01ef),
203
- )
204
- _IDNA_DOTS = re.compile("[.\u3002\uff0e\uff61]")
205
- _U32 = unicodedata.ucd_3_2_0
206
- _RTL_OK = {"R", "AL", "AN", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"}
207
- _JOINERS = "\u200c\u200d"
208
-
209
-
210
- def _in_ranges(ranges: Sequence[tuple], cp: int) -> bool:
211
- i = bisect.bisect_right(ranges, (cp, 0x10ffff)) - 1
212
- return i >= 0 and ranges[i][0] <= cp <= ranges[i][1]
213
-
214
-
215
- def _known(c: str) -> bool:
216
- return _U32.category(c) != "Cn" and unicodedata.category(c) != "Cn"
217
-
218
-
219
- def _joiner_refused(label: str, i: int) -> bool:
220
- """CheckJoiners as Node 20 to 26 all read it: they find a joining context, or a virama, anywhere in the label
221
- (`\u0628a\u200c\u0627` and `\u0628\u200c\u0627\u200d` parse), so only a joiner with nothing but ASCII beside it
222
- in its label has none for certain."""
223
- return all(c.isascii() for j, c in enumerate(label) if j != i)
224
-
225
-
226
- def _bidi_refused(label: str) -> bool:
227
- """RFC 5893 as Node 20 to 26 all read it: only a label holding R, AL or AN is judged, and its first code point is
228
- not (Node 26 alone refuses a label opening with a digit or a hyphen)."""
229
- if not label or not all(_known(c) for c in label) or any(c in _JOINERS for c in label):
230
- return False # Node 20 to 26 pass a label holding a joiner over (`\u05d0a\u094d\u200d` parses)
231
- k = [_U32.bidirectional(c) for c in label]
232
- if not any(x in ("R", "AL", "AN") for x in k):
233
- return False
234
- body = list(k)
235
- while body and body[-1] == "NSM":
236
- body.pop()
237
- if not body or body[-1] not in ("R", "AL", "EN", "AN"):
238
- return True
239
- return k[0] in ("R", "AL") and (any(x not in _RTL_OK for x in k) or ("EN" in k and "AN" in k))
240
-
241
-
242
- def _idna_host(host: str) -> Optional[str]:
243
- """None where WHATWG's domain-to-ASCII refuses a non-ASCII host for certain; else the host as UTS46 maps it, read
244
- as NFKC with case folding (exact enough for the ASCII rules that follow, which read digits and dots)."""
245
- if any(_in_ranges(_IDNA_DISALLOWED, ord(c)) for c in host):
246
- return None
247
- kept = "".join(c for c in host if not _in_ranges(_IDNA_IGNORED, ord(c)))
248
- if kept == "":
249
- return None # nothing left: an empty host
250
- if not all(_known(c) for c in kept):
251
- return host # a code point this Python's Unicode does not know: the contextual rules are not read
252
- mapped = unicodedata.normalize("NFC", unicodedata.normalize("NFKC", unicodedata.normalize("NFKC", kept).casefold()))
253
- for label in _IDNA_DOTS.split(mapped):
254
- first = label[:1]
255
- if first and first not in "\u17b4\u17b5" and _U32.category(first)[0] == "M" and unicodedata.category(first)[0] == "M":
256
- return None # a label opening with a mark (U+17B4 and U+17B5 are ignored by the newer Nodes)
257
- if any(c in _JOINERS and _joiner_refused(label, i) for i, c in enumerate(label)):
258
- return None
259
- if _bidi_refused(label):
260
- return None
261
- return _IDNA_DOTS.sub(".", mapped)
262
-
263
-
264
- def _ipv4_number(part: str) -> Optional[int]:
265
- if part == "":
266
- return None # an empty part fails WHATWG's IPv4 parser (`1..2`); `0x` alone is 0
267
- base = 10
268
- if part[:2].lower() == "0x":
269
- part, base = part[2:], 16
270
- elif len(part) > 1 and part[0] == "0":
271
- part, base = part[1:], 8
272
- if part == "":
273
- return 0
274
- digits = {10: "0123456789", 16: "0123456789abcdefABCDEF", 8: "01234567"}[base]
275
- return int(part, base) if all(c in digits for c in part) else None
276
-
277
-
278
- def _domain_ok(host: str) -> bool:
279
- """The WHATWG host parser on a special scheme's host, enough to know when `new URL` refuses it."""
280
- if host.startswith("["):
281
- return host.endswith("]") and _ipv6_ok(host[1:-1])
282
- if re.search(r"%(?![0-9A-Fa-f]{2})", host) is None:
283
- try:
284
- host = urllib.parse.unquote_to_bytes(host).decode("utf-8")
285
- except UnicodeDecodeError:
286
- return False
287
- if any(c in _FORBIDDEN_DOMAIN for c in host):
288
- return False
289
- # A non-ASCII host goes through UTS46 in WHATWG; this module reads it narrowly (_idna_host, FAA-98). When unsure,
290
- # the host is taken as valid, so its URL's credentials are read: the doubt then hides a list rather than publish one
291
- # (round-2 reading). A host UTS46 maps to ASCII is read by the ASCII rules below (`127.0.0.1` is an address).
292
- if any(ord(c) > 0x7f for c in host):
293
- mapped = _idna_host(host)
294
- if mapped is None:
295
- return False
296
- if not mapped.isascii() or any(c in _FORBIDDEN_DOMAIN for c in mapped):
297
- return True
298
- host = mapped
299
- labels = host.split(".")
300
- if labels and labels[-1] == "" and len(labels) > 1:
301
- labels = labels[:-1]
302
- last = labels[-1] if labels else ""
303
- if not (last and (last.isdigit() and last.isascii() or re.fullmatch(r"0[xX][0-9A-Fa-f]*", last))):
304
- return host != ""
305
- numbers = [_ipv4_number(label) for label in labels]
306
- if len(numbers) > 4 or any(n is None for n in numbers):
307
- return False
308
- return all(n <= 255 for n in numbers[:-1]) and numbers[-1] < 256 ** (5 - len(numbers))
309
-
310
-
311
- def _ipv6_ok(text: str) -> bool:
312
- if "%" in text:
313
- return False # a zone id: `ipaddress` takes one, WHATWG does not
314
- try:
315
- ipaddress.IPv6Address(text)
316
- return True
317
- except ValueError:
318
- return False
319
-
320
-
321
197
  _SURROGATE = re.compile("[\ud800-\udfff]")
322
198
  _QUERY_ENCODE = set(chr(c) for c in range(0x21)) | set('"#<>')
323
199
  _FRAGMENT_ENCODE = set(chr(c) for c in range(0x21)) | set('"<>`')
@@ -388,7 +264,7 @@ def _whatwg_parts(value: str) -> Optional[Dict[str, str]]:
388
264
  return None
389
265
  elif any(c in _FORBIDDEN_HOST for c in host):
390
266
  return None
391
- elif host == "" and ("@" in authority or port):
267
+ elif host == "" and ("@" in authority or ":" in hostport):
392
268
  return None
393
269
  return {"password": password, "query": query, "fragment": fragment}
394
270
 
@@ -424,7 +300,7 @@ def _secret_url(value: str) -> bool:
424
300
 
425
301
 
426
302
  def _credential(value: str, runs: bool) -> bool:
427
- return bool(JWT_SHAPE.search(value) or _KEY_SHAPE.search(value) or _BEARER.search(value)
303
+ return bool(has_jwt(value) or _KEY_SHAPE.search(value) or _BEARER.search(value) # has_jwt: one pass (FAA-313)
428
304
  or (runs and _long_run(value)) or any(_secret_url(url) for url in _urls_in(value)))
429
305
 
430
306
 
@@ -46,6 +46,8 @@ from fabric_jsvalue import ( # noqa: E402 - the core (FAA-65)
46
46
  )
47
47
  from fabric_paths import atomic_write as _atomic_write # noqa: E402 - the kit's private writer (FAA-85)
48
48
  import fabric_peer_owner as _po # noqa: E402 - a loopback key set is read from this account's listener only (FAA-255)
49
+ import fabric_secrets # noqa: E402 - the WHATWG host reading (FAA-98, FAA-309)
50
+ import fabric_socket_proof # noqa: E402 - the one loopback host rule (FAA-187 (15))
49
51
  from fabric_mandate import effect_closure # noqa: E402,F401 - defined once beside the mandate that reads it too (DEC-0125)
50
52
 
51
53
  REFUSAL_CODES = (
@@ -621,16 +623,10 @@ def action_digest(action: Any) -> str:
621
623
 
622
624
  # --- the estate's keys ----------------------------------------------------------------------------------------
623
625
 
624
- def _loopback(host: str) -> bool:
625
- if host == "localhost":
626
- return True
627
- try:
628
- address = ipaddress.ip_address(host)
629
- except ValueError:
630
- return False
631
- # A zone (`::1%eth0`) is no loopback: urllib decodes `%3A80` in it into another address (0.56.0 seam r13).
632
- # Nor an IPv4-mapped address, which Python 3.13+ counts as loopback and 3.9 and Node do not (0.56.0 seam r14).
633
- return address.is_loopback and not getattr(address, "scope_id", None) and not getattr(address, "ipv4_mapped", None)
626
+ #: A key set source's loopback host, the kit's one rule (fabric_socket_proof.loopback_host), shared with fabric_http's
627
+ #: check_url, so a source starts a service on both kits alike (FAA-187 (15); `127.1`, `2130706433`, a name under
628
+ #: `.localhost`). A zone and an IPv4-mapped address stay refused, as before (0.56.0 seam r13, r14).
629
+ _loopback = fabric_socket_proof.loopback_host
634
630
 
635
631
 
636
632
  # fabric_http._URL_ENDS (this module may not import fabric_http; test_fabric_delegation pins them equal).
@@ -663,7 +659,16 @@ def _source_authority_ok(parts: urllib.parse.SplitResult) -> bool:
663
659
  parts.port # noqa: B018 - raises on a port above 65535
664
660
  except ValueError:
665
661
  return False
666
- return bool(parts.hostname)
662
+ # A host `new URL` refuses for its structure (`a127.0.0.1`, `09`), or one Python's IDNA codec cannot write for
663
+ # http.client (U+180E), is refused here as check_url refuses it, never read and failed on every call (FAA-187 (14),
664
+ # (16)).
665
+ host = parts.hostname or ""
666
+ try:
667
+ if not host.isascii():
668
+ host.encode("idna")
669
+ except UnicodeError:
670
+ return False
671
+ return bool(host) and not fabric_secrets.url_refused(parts.geturl())
667
672
 
668
673
 
669
674
  class _NoRedirect(urllib.request.HTTPRedirectHandler):
@@ -734,7 +739,7 @@ def _fetch(source: str) -> Any:
734
739
  body += chunk
735
740
  if deadline["fired"] or time.monotonic() > deadline["end"]:
736
741
  raise TimeoutError("the key set did not arrive within %s s" % JWKS_FETCH_SECONDS)
737
- return json.loads(body[:65536])
742
+ return json.loads(body[:65536], parse_constant=_no_constant) # NaN is no JSON, as JSON.parse reads it (FAA-194)
738
743
  except urllib.error.HTTPError as exc:
739
744
  exc.close()
740
745
  raise
@@ -745,7 +750,7 @@ def _fetch(source: str) -> Any:
745
750
  finally:
746
751
  if deadline["timer"] is not None:
747
752
  deadline["timer"].cancel()
748
- return json.loads(Path(source).expanduser().read_text())
753
+ return json.loads(Path(source).expanduser().read_text(), parse_constant=_no_constant)
749
754
 
750
755
 
751
756
  class JwksCache: