@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
@@ -55,7 +55,7 @@ from fabric_usage import (
55
55
  DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
56
56
 
57
57
 
58
- DASHBOARD_KIT = "fabric-dashboard-kit@1.1.6"
58
+ DASHBOARD_KIT = "fabric-dashboard-kit@1.1.8"
59
59
 
60
60
 
61
61
  REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
@@ -197,9 +197,11 @@ def read_descriptors(directory: Optional[Path] = None) -> List[Tuple[Path, Dict[
197
197
  return found
198
198
  for path in sorted(root.glob("*.json")):
199
199
  try:
200
- found.append((path, json.loads(path.read_text(encoding="utf-8"))))
201
- except (OSError, ValueError):
200
+ value = json.loads(path.read_text(encoding="utf-8"))
201
+ except (OSError, ValueError, RecursionError):
202
202
  continue
203
+ if isinstance(value, dict): # a file that holds no object is no descriptor (FAA-200 (50))
204
+ found.append((path, value))
203
205
  return found
204
206
 
205
207
 
@@ -222,10 +224,11 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
222
224
  return problems
223
225
  if descriptor["protocol"] != PROTOCOL:
224
226
  problems.append("protocol must be %s" % PROTOCOL)
225
- if not _ID.match(str(descriptor["id"])):
226
- problems.append("id must match %s" % _ID.pattern)
227
- if not _INSTANCE.match(str(descriptor["instance"])):
228
- problems.append("instance must match %s" % _INSTANCE.pattern)
227
+ for key, pattern in (("id", _ID), ("instance", _INSTANCE)): # a string before its pattern (FAA-200 (9), (36))
228
+ if not isinstance(descriptor[key], str):
229
+ problems.append("%s must be a string" % key)
230
+ elif not pattern.match(descriptor[key]):
231
+ problems.append("%s must match %s" % (key, pattern.pattern))
229
232
  if remote:
230
233
  problems.extend(remote_origin_problems(descriptor["origin"]))
231
234
  elif "origin" in descriptor and not _ORIGIN.match(str(descriptor["origin"])):
@@ -234,11 +237,11 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
234
237
  if "tokenFile" not in auth:
235
238
  problems.append("auth.tokenFile is required")
236
239
  elif not _LOCAL_PATH.match(str(auth["tokenFile"])):
237
- problems.append("auth.tokenFile must be a local path, never a network share")
240
+ problems.append("auth.tokenFile must be an absolute local path (/, ~/ or a drive letter), never a relative path, never a network share")
238
241
  paths = descriptor.get("paths") or {}
239
242
  for value in ([paths["data"]] if "data" in paths else []) + list(paths.get("logs") or []):
240
243
  if not _LOCAL_PATH.match(str(value)):
241
- problems.append("paths must be local paths, never a network share: %r" % value)
244
+ problems.append("paths must be absolute local paths (/, ~/ or a drive letter), never a relative path, never a network share: %r" % value)
242
245
  header = auth.get("header", "Authorization")
243
246
  scheme = auth.get("scheme", "Bearer")
244
247
  if not (isinstance(header, str) and _AUTH_HEADER.fullmatch(header)):
@@ -405,7 +408,7 @@ def descriptor_member_problems(descriptor: Dict[str, Any]) -> List[str]:
405
408
  elif "@" in repository and not _HIERARCHICAL.match(repository): # 0.62.0 readings 7: never an scp form's user
406
409
  problems.append("source.repository with a user is a URL with an authority (https://user@host/path), never an scp form")
407
410
  elif fabric_secrets.repository_credential(repository): # its name is no key (0.62.0 seam reading 6)
408
- problems.append("FAC-SEM-086: /source/repository holds a value shaped like a credential; it names a repository")
411
+ problems.append("FAC-SEM-086: /source/repository holds a value shaped like a credential; it names a repository: write the repository's name in lower case if it is one")
409
412
  if "peer" in descriptor:
410
413
  peer = descriptor.get("peer")
411
414
  if not isinstance(peer, dict):
@@ -589,8 +592,15 @@ def port_listener_refusal(port: int, reader: Optional[Any] = None) -> Optional[s
589
592
  "FAA-225)." % (port, uid))
590
593
 
591
594
 
595
+ def warn_no_source(descriptor: Dict[str, Any], stacklevel: int = 2) -> None:
596
+ """The rewrite warning of a descriptor that names no source.repository (FAA-198)."""
597
+ me = "%s.%s" % (descriptor["id"], descriptor["instance"])
598
+ warnings.warn("Descriptor %s %s; a new install is refused without it." % (me, NO_SOURCE_REPOSITORY),
599
+ NoSourceRepositoryWarning, stacklevel=stacklevel + 1)
600
+
601
+
592
602
  def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = None, *, _stacklevel: int = 2,
593
- port_owner: Optional[Any] = None) -> Path:
603
+ port_owner: Optional[Any] = None, _warned: bool = False) -> Path:
594
604
  """Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim), and a port another
595
605
  OS account listens on (FAA-225; ``port_owner(port) -> (kind, uid) | None`` reads it, the kit's own by default).
596
606
 
@@ -604,9 +614,8 @@ def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = Non
604
614
  refusal = source_refusal(descriptor, root)
605
615
  if refusal:
606
616
  raise ServiceError(refusal)
607
- if not names_repository(descriptor):
608
- warnings.warn("Descriptor %s %s; a new install is refused without it." % (me, NO_SOURCE_REPOSITORY),
609
- NoSourceRepositoryWarning, stacklevel=_stacklevel)
617
+ if not names_repository(descriptor) and not _warned: # register_remote warns itself, before its token (FAA-200 (1))
618
+ warn_no_source(descriptor, _stacklevel)
610
619
  # DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
611
620
  port = None if placement_of(descriptor) == "remote" or "origin" not in descriptor else port_of(descriptor["origin"])
612
621
  for path, other in read_descriptors(root):
@@ -51,7 +51,9 @@ def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subj
51
51
  link: Optional[str] = None, notify: bool = False, trace_id: Optional[str] = None,
52
52
  span_id: Optional[str] = None, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
53
53
  """One activity event: one sentence a person reads, never a machine id. `level` is one of `LEVELS`; `warn`, the
54
- dashboard chip's name, is stored as `warning`. `data` (DEC-0070) adds machine-readable
54
+ dashboard kit's chip for "a person is needed" (`.fd-s-warn`), is stored as `warning`, the feed's name for the same
55
+ level, so a reader that draws chips shows a stored `warning` as the `warn` chip (FAA-276, FAA-277 (6)). `data`
56
+ (DEC-0070) adds machine-readable
55
57
  fields beside the sentence, `{schema, value}`, never a secret: it is refused here, so no feed carries one.
56
58
 
57
59
  An event about traced work carries its trace as a pair, `trace_id` and `span_id`
@@ -21,7 +21,8 @@ says so three ways:
21
21
 
22
22
  `HoldBook` keeps one JSON file (0600, written atomically); a ThreadingHTTPServer's handlers share it under the store
23
23
  lock. It holds caller names, work labels, reasons and the operator's words, never a credential: `hold` refuses a text
24
- the contract's detector reads as one.
24
+ the contract's detector reads as one. A record written since 0.83.0 carries `squash: "ecmascript"`; one without it
25
+ (0.81.0, 0.82.0) is matched under both squashes, since 0.81.0's Python twin squashed names with str.split() (FAA-320).
25
26
  """
26
27
  from __future__ import annotations
27
28
 
@@ -55,6 +56,35 @@ LIFT_ACTION = "limit.lift"
55
56
  _MAX_STATUS_MESSAGE = 500
56
57
  _WAITING = re.compile(r"^waiting (daily-quota|caller-share|concurrency|money) since ([0-9][0-9TZ:.-]*?)(?:: ([\s\S]+))?\Z")
57
58
  _SIZES = ("jobs", "usd", "limit")
59
+ #: One run of ECMAScript's whitespace, the class both twins squash with (FAA-316): Python's str.split() also takes
60
+ #: U+001C to U+001F and U+0085 and not U+FEFF, so the twins wrote different statusMessages.
61
+ _SPACES = re.compile("[%s]+" % fabric_secrets.JS_SPACE)
62
+
63
+
64
+ def _squash(text: str) -> str:
65
+ """Runs of ECMAScript whitespace as one space, none at either end (the Node twin's `squash`)."""
66
+ return _SPACES.sub(" ", text).strip(" ")
67
+
68
+
69
+ #: What a record written since 0.83.0 carries: its names were squashed with ECMAScript's whitespace (FAA-320).
70
+ SQUASH = "ecmascript"
71
+ #: Python's str.isspace() class, the one 0.81.0's Python twin squashed a name with (`" ".join(text.split())`).
72
+ _PY_SPACES = re.compile("[\t\n\x0b\x0c\r\x1c-\x1f \x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+")
73
+
74
+
75
+ def _legacy_squash(text: str) -> str:
76
+ """A name as 0.81.0's Python twin wrote it (str.split()), written out so both twins read it alike."""
77
+ return _PY_SPACES.sub(" ", text).strip(" ")
78
+
79
+
80
+ def _names(caller: Any, record: Dict[str, Any]) -> tuple:
81
+ """The stored forms `caller` matches in `record`: the ECMAScript squash, and for a record with no `squash` mark
82
+ (written before 0.83.0) also the squash 0.81.0's Python twin wrote (FAA-320). None matches only None."""
83
+ if not isinstance(caller, str):
84
+ return (caller,)
85
+ if record.get("squash") == SQUASH:
86
+ return (_squash(caller),)
87
+ return (_squash(caller), _legacy_squash(caller))
58
88
 
59
89
 
60
90
  def _instant(value: Any, name: str) -> str:
@@ -75,10 +105,10 @@ def waiting_message(reason: str, since: str, until: Optional[str] = None, messag
75
105
  text = "waiting %s since %s" % (reason, since)
76
106
  head = len(QUOTA_EXHAUSTED) + (0 if until is None else len(" until ") + len(until)) + len(": ")
77
107
  if message is not None:
78
- message = " ".join(str(message).split())
108
+ message = _squash(str(message))
79
109
  room = _MAX_STATUS_MESSAGE - head - len(text) - len(": ")
80
110
  if message and room > 0:
81
- text += ": " + (message if len(message) <= room else message[:room - 3].rstrip() + "...")
111
+ text += ": " + (message if len(message) <= room else message[:room - 3].rstrip(" ") + "...")
82
112
  return pause_message(until, text)
83
113
 
84
114
 
@@ -124,11 +154,11 @@ def hold_sentence(caller: str, work: str, reason: str, until: Optional[str]) ->
124
154
 
125
155
 
126
156
  def _text(value: Any, name: str, high: int = 200) -> str:
127
- if not isinstance(value, str) or not value.strip() or len(value) > high:
157
+ if not isinstance(value, str) or not _squash(value) or len(value) > high:
128
158
  raise ServiceError("%s is a text of 1 to %d characters." % (name, high))
129
159
  if fabric_secrets.secret_value(value):
130
160
  raise ServiceError("%s looks like a credential; a hold names callers and work, never a secret." % name)
131
- return " ".join(value.split())
161
+ return _squash(value)
132
162
 
133
163
 
134
164
  class HoldBook:
@@ -189,6 +219,7 @@ class HoldBook:
189
219
  job_id: Optional[str] = None, now: Optional[str] = None) -> Dict[str, Any]:
190
220
  """Hold `work` for `caller` by `reason`. Returns {hold, statusMessage, push}: `push` is the notify event when this
191
221
  call opened the hold, None when the work joined an open one (one push per hold)."""
222
+ raw_caller = caller
192
223
  caller, work = _text(caller, "caller"), _text(work, "work")
193
224
  if reason not in HOLD_REASONS:
194
225
  raise ServiceError("A hold's reason is one of %s." % ", ".join(HOLD_REASONS))
@@ -199,11 +230,11 @@ class HoldBook:
199
230
  data = self._load()
200
231
  self._expire(data, utc_time(at))
201
232
  hold = next((h for h in data["holds"] if h.get("closed") is None and h["reason"] == reason
202
- and h["caller"] == caller), None)
233
+ and h["caller"] in _names(raw_caller, h)), None)
203
234
  opened = hold is None
204
235
  if opened:
205
236
  hold = {"id": uuid.uuid4().hex, "reason": reason, "caller": caller, "work": work, "since": at,
206
- "until": until, "jobs": [], "closed": None}
237
+ "until": until, "jobs": [], "closed": None, "squash": SQUASH}
207
238
  data["holds"].append(hold)
208
239
  if job_id is not None and job_id not in hold["jobs"]:
209
240
  hold["jobs"].append(str(job_id))
@@ -221,7 +252,7 @@ class HoldBook:
221
252
  with self._lock:
222
253
  data = self._load()
223
254
  hold = next((h for h in data["holds"] if h.get("closed") is None and h["reason"] == reason
224
- and h["caller"] == caller), None)
255
+ and h["caller"] in _names(caller, h)), None)
225
256
  if hold is None:
226
257
  return None
227
258
  hold["closed"], hold["closedAt"] = "released", at
@@ -261,14 +292,15 @@ class HoldBook:
261
292
  raise ServiceError("A raised limit needs its window's end (until): it falls back when the window ends.")
262
293
  record: Dict[str, Any] = {"id": uuid.uuid4().hex, "reason": reason, "caller": None if caller is None else _text(caller, "caller"),
263
294
  "job": None if job_id is None else str(job_id), "from": at, "until": until,
264
- given[0]: size, "decision": dict(decision), "at": at}
295
+ given[0]: size, "decision": dict(decision), "at": at, "squash": SQUASH}
265
296
  if given[0] != "limit":
266
297
  record["left"] = size
267
298
  with self._lock:
268
299
  data = self._load()
269
300
  self._expire(data, utc_time(at))
270
301
  for hold in data["holds"]:
271
- if hold.get("closed") is None and hold["reason"] == reason and record["caller"] in (None, hold["caller"]) and (
302
+ if hold.get("closed") is None and hold["reason"] == reason and (
303
+ caller is None or hold["caller"] in _names(caller, hold)) and (
272
304
  job_id is None or str(job_id) in hold["jobs"]):
273
305
  hold["closed"], hold["closedAt"], hold["lift"] = "lifted", at, record["id"]
274
306
  data["lifts"].append(record)
@@ -283,7 +315,7 @@ class HoldBook:
283
315
  def _matching(self, data: Dict[str, Any], reason: str, caller: Optional[str], job_id: Optional[str], at: int,
284
316
  kind: str) -> List[Dict[str, Any]]:
285
317
  return [l for l in data["lifts"] if l["reason"] == reason and kind in l and self._live(l, at)
286
- and l.get("caller") in (None, caller) and l.get("job") in (None, job_id)]
318
+ and (l.get("caller") is None or l["caller"] in _names(caller, l)) and l.get("job") in (None, job_id)]
287
319
 
288
320
  def admit(self, *, reason: str, caller: str, job_id: Optional[str] = None, cost_usd: Optional[float] = None,
289
321
  now: Optional[str] = None) -> Dict[str, Any]:
@@ -50,6 +50,8 @@ from pathlib import Path as _Path # noqa: E402
50
50
  if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
51
51
  _sys.path.insert(0, str(_Path(__file__).resolve().parent))
52
52
  from fabric_jsvalue import json_stringify # noqa: E402 - beside this file
53
+ import fabric_secrets # noqa: E402 - beside this file: the WHATWG host reading (FAA-98, FAA-309)
54
+ import fabric_socket_proof # noqa: E402 - beside this file: the one loopback host rule (FAA-187 (15))
53
55
  import fabric_peer_owner as _po # noqa: E402 - beside this file: the listener's owner (DEC-0154 HA-2)
54
56
 
55
57
  RETRY_STATUSES = (408, 425, 429, 500, 502, 503, 504)
@@ -73,8 +75,9 @@ class HttpError(Exception):
73
75
  self.retry_after_s = retry_after_s
74
76
  self.body = bytes(body[:2048])
75
77
  self.kind = kind # http (an answer), connection, timeout, too-large, refused, foreign-user
76
- #: For foreign-user, why (FAA-272): `table-restricted` (this process sees no other process's sockets, which
77
- #: never clears), the owner's kind (`another-account`, `system`) or the check's reason (`no-row`).
78
+ #: For foreign-user, why (FAA-272): `table-restricted` (this process sees no other process's sockets, or on
79
+ #: macOS no row for this connection, DEC-0155 RT-1; it never clears), the owner's kind (`another-account`,
80
+ #: `system`) or the check's reason (`no-row`, from Linux and Windows only).
78
81
  self.reason = reason
79
82
 
80
83
 
@@ -210,28 +213,28 @@ def redact_url(url: str) -> str:
210
213
  return urllib.parse.urlunsplit((parts.scheme.lower(), netloc, path, "", ""))
211
214
 
212
215
 
213
- def _is_loopback(host: str) -> bool:
214
- """Loopback as a WHATWG URL parser leaves the host: one trailing dot dropped from an IPv4 address, no whitespace,
215
- and an IPv4-mapped IPv6 address not counted (Node writes it in hex and the Node twin refuses it)."""
216
- if host == "localhost" or host.endswith(".localhost"):
216
+ #: Loopback as a WHATWG URL parser leaves the host: the kit's one rule, shared with fabric_delegation (FAA-187 (15)).
217
+ _is_loopback = fabric_socket_proof.loopback_host
218
+
219
+
220
+ def _idna_sendable(host: Optional[str]) -> bool:
221
+ """Whether http.client can write the host: a non-ASCII one goes out in IDNA through Python's own codec, which refuses
222
+ some hosts `new URL` maps (U+180E, a fullwidth dot beside a dot), so such a URL would fail on every call (FAA-187
223
+ (16)); it is refused at the check instead."""
224
+ if not host or host.isascii():
217
225
  return True
218
- if not host or any(c.isspace() for c in host):
219
- return False
220
226
  try:
221
- address = ipaddress.ip_address(host)
222
- return address.is_loopback and not getattr(address, "ipv4_mapped", None)
223
- except ValueError:
224
- pass
225
- host = host[:-1] if host.endswith(".") else host
226
- try: # the short IPv4 forms a WHATWG URL also reads (127.1, 2130706433, 0x7f.1)
227
- return socket.inet_aton(host)[0] == 127
228
- except (OSError, ValueError):
227
+ host.encode("idna")
228
+ return True
229
+ except UnicodeError:
229
230
  return False
230
231
 
231
232
 
232
233
  def check_url(url: str, *, insecure: bool = False) -> None:
233
234
  """Refuse what this client never sends: a scheme other than http or https, no host, credentials in the URL, or
234
- plain http to a host that is not loopback (unless `insecure`)."""
235
+ plain http to a host that is not loopback (unless `insecure`). A host `new URL` refuses for its structure (a last
236
+ label that is a number but no IPv4 address, `a127.0.0.1`, `09`; a host UTS46 refuses, a joiner beside ASCII alone) is
237
+ refused as the Node twin refuses it, read by fabric_secrets.url_refused (FAA-187 (14))."""
235
238
  try:
236
239
  # The ends are trimmed as urllib trims what it sends (and `new URL` too); a URL ending in a line break, which
237
240
  # 0.55.0 sent, still passes (0.56.0 product r17).
@@ -241,7 +244,7 @@ def check_url(url: str, *, insecure: bool = False) -> None:
241
244
  parts = urllib.parse.urlsplit(sent)
242
245
  host = parts.hostname
243
246
  parts.port # noqa: B018 - raises on a bad port
244
- if not _authority_ok(parts.netloc):
247
+ if not _authority_ok(parts.netloc) or fabric_secrets.url_refused(sent) or not _idna_sendable(host):
245
248
  raise ValueError("authority")
246
249
  except (ValueError, TypeError):
247
250
  raise HttpError("the URL does not parse", url=redact_url(url), kind="refused")
@@ -70,8 +70,9 @@ class HubError(Exception):
70
70
  def listener_refusal(exc: Any) -> HubError:
71
71
  """The HubError for a hub port the peer-owner check refused (DEC-0154 HA-2): code `foreign-user`, the pinned
72
72
  contract's state name, with the owner's reason in the sentence and in `data.reason` (FAA-272): `table-restricted`
73
- when this process's socket table hides other processes' sockets, which never clears, so it is not retryable;
74
- otherwise the owner's kind or the check's reason (`another-account`, `no-row`), retryable as before."""
73
+ when this process's socket table hides other processes' sockets, or, on macOS, holds no row for this connection
74
+ (DEC-0155 RT-1, FAA-312), which never clears, so it is not retryable; otherwise the owner's kind or the check's
75
+ reason (`another-account`, or `no-row`, which only Linux and Windows give), retryable."""
75
76
  owner = getattr(exc, "owner", None)
76
77
  why = (getattr(owner, "reason", None) or getattr(owner, "kind", None) or getattr(exc, "code", None) or "unknown")
77
78
  reason = _po.TABLE_RESTRICTED if str(why).startswith(_po.TABLE_RESTRICTED) else str(why).split(":", 1)[0]
@@ -94,25 +95,41 @@ def fabric_root(platform: str = sys.platform, env: Optional[Dict[str, str]] = No
94
95
  return Path(env.get("XDG_DATA_HOME") or str(home / ".local/share")) / "passioncode-fabric"
95
96
 
96
97
 
97
- def pid_alive(pid: int) -> bool:
98
- """Whether a process with this id runs as this user (Fabric `hub.ts#pidAlive`); never signals it."""
98
+ def _windows_pid_alive(pid: int, kernel: Any = None, last_error: Any = None) -> Optional[bool]:
99
+ """pid_alive on Windows, where os.kill(pid, 0) would send CTRL_C_EVENT: the check Node's process.kill(pid, 0) makes
100
+ (libuv's uv_kill), asking OpenProcess for the same access (terminate, query, synchronize; never the limited query an
101
+ elevated process of the same user grants, FAA-323). Refused with ERROR_ACCESS_DENIED (5), another account's or an
102
+ elevated process: None, as Node answers EPERM there (FAA-304); refused otherwise, no process; opened, running
103
+ while its exit code is STILL_ACTIVE and a zero wait times out. `kernel` and `last_error` stand in for kernel32 and
104
+ ctypes.get_last_error in a test."""
105
+ import ctypes
106
+ if kernel is None:
107
+ kernel = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined]
108
+ last_error = ctypes.get_last_error # type: ignore[attr-defined]
109
+ handle = kernel.OpenProcess(0x0001 | 0x0400 | 0x00100000, False, pid) # TERMINATE | QUERY_INFORMATION | SYNCHRONIZE
110
+ if not handle:
111
+ return None if last_error() == 5 else False # ERROR_ACCESS_DENIED: not verified, never called running
112
+ try:
113
+ code = ctypes.c_ulong()
114
+ if not kernel.GetExitCodeProcess(handle, ctypes.byref(code)) or code.value != 259: # STILL_ACTIVE
115
+ return False
116
+ return kernel.WaitForSingleObject(handle, 0) != 0 # WAIT_OBJECT_0: ended, with exit code 259
117
+ finally:
118
+ kernel.CloseHandle(handle)
119
+
120
+
121
+ def pid_alive(pid: int) -> Optional[bool]:
122
+ """True: a process of this OS account runs under `pid`; False: none does. None (falsy): a process of another account
123
+ does (EPERM; on Windows, OpenProcess refused with access denied), as when the id hub.json names was reused: not
124
+ verified, never called running (FAA-304, the session hooks' answer since FAA-254). Never signals it."""
99
125
  if pid <= 0:
100
126
  return False
101
127
  if sys.platform == "win32":
102
- import ctypes
103
- kernel = ctypes.windll.kernel32 # type: ignore[attr-defined]
104
- handle = kernel.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
105
- if not handle:
106
- return False
107
- try:
108
- code = ctypes.c_ulong()
109
- return bool(kernel.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
110
- finally:
111
- kernel.CloseHandle(handle)
128
+ return _windows_pid_alive(pid)
112
129
  try:
113
130
  os.kill(pid, 0)
114
131
  except PermissionError:
115
- return False # another user's process under a reused id: not this user's Fabric
132
+ return None # another account's process under a reused id: not verified, and not this account's Fabric
116
133
  except OSError:
117
134
  return False
118
135
  return True
@@ -142,7 +159,8 @@ def _read_small(path: Path) -> Optional[str]:
142
159
  def locate_hub(root: Optional[Path] = None, alive: Callable[[int], bool] = pid_alive, estate: Optional[Dict[str, Any]] = None,
143
160
  now: Optional[float] = None) -> Dict[str, Any]:
144
161
  """`hub.json` read the way an agent must, every call. Answers `{origin, mcpUrl, doorTokenFile, pid, startedAt,
145
- features}` or raises `HubError` (`not-running`, retryable; `not-a-hub`). Reads no token. `estate` is
162
+ features}` or raises `HubError` (`not-running`, retryable; `not-a-hub`, retryable when hub.json names a process of
163
+ another OS account, which is not verified, FAA-304). Reads no token. `estate` is
146
164
  `{"record": <estate record>, "applied": {"revision", "digest"?} | None}`: with it, a `hubKey` the document names is
147
165
  checked by `hub_key_certified` (DEC-0060); fabric-hub/0.1 names none, and then nothing is checked."""
148
166
  root = Path(root) if root is not None else fabric_root()
@@ -174,7 +192,11 @@ def locate_hub(root: Optional[Path] = None, alive: Callable[[int], bool] = pid_a
174
192
  token_file = doc.get("doorTokenFile") if isinstance(doc.get("doorTokenFile"), str) else ""
175
193
  if not os.path.isabs(token_file) or os.path.realpath(os.path.dirname(token_file)) != os.path.realpath(str(root)):
176
194
  raise not_hub("names a door token outside the registry root")
177
- if not alive(pid):
195
+ live = alive(pid)
196
+ if live is None: # FAA-304: another account's process under that id, so not this account's Fabric (as FAA-254)
197
+ raise HubError("not-a-hub", "Fabric's hub.json names process %d, which runs under another OS account: not "
198
+ "verified." % pid, True)
199
+ if not live:
178
200
  raise HubError("not-running", "Fabric is not running: hub.json names process %d, which is not running." % pid, True)
179
201
  features = doc.get("features")
180
202
  features = [f for f in features if isinstance(f, str)] if isinstance(features, list) else []