@passioncode-ai/fabric-agent-adapter 0.78.0 → 0.80.2

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 (105) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/README.md +14 -7
  3. package/bin/fabric-agent-adapter.js +26 -4
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +42 -7
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +3 -3
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +12 -4
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +23 -8
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +6 -2
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +2 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -2
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +34 -8
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +11 -6
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +42 -7
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +6 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +26 -3
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -2
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +14 -4
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +6 -2
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +12 -4
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +7 -5
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +10 -5
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +15 -6
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +3 -2
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +48 -5
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +9 -3
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +8 -2
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +55 -14
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +16 -7
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +4 -2
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +49 -3
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +9 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +17 -7
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +6 -2
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +24 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -16
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +543 -37
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
  82. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +12 -5
  83. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
  84. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +42 -7
  85. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
  86. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
  87. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +13 -4
  88. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +32 -0
  89. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
  90. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +12 -4
  91. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  92. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -26
  93. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
  94. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
  95. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +59 -38
  96. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +52 -26
  97. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
  98. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
  99. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
  100. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
  101. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
  102. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +169 -46
  103. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +146 -19
  104. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
  105. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
@@ -13,7 +13,8 @@ not re-export this module, so a service that does not run per context keeps the
13
13
  An organization's instance whose context the record does not pin (``organizations``, DEC-0127) still starts: the helper
14
14
  checks the placement and its share grant, not the pin (decision D5 of 0.52.0). Its tokens are refused instead:
15
15
  ``fabric_estate.hub_key_certified`` reads a context's hub keys only while the record pins that organization (DL-14a,
16
- DEC-0128), so the service passes ``estate_context=info["context"]`` to its delegation gate. The contract lets Fabric
16
+ DEC-0128), so the service passes ``context_gate_options(info, record)`` (its context and its pin's issuer, FAA-150) to its
17
+ delegation gate. The contract lets Fabric
17
18
  write ``organizations`` only once every reader is pinned at or after DEC-0127, so a refusal at start would stop every
18
19
  organization instance on today's records."""
19
20
  from __future__ import annotations
@@ -33,7 +34,8 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
33
34
  from fabric_base import ServiceError, _INSTANCE # noqa: E402
34
35
  from fabric_descriptor import descriptor_path # noqa: E402
35
36
  from fabric_estate import parse_context # noqa: E402
36
- from fabric_lock import InstanceLock, hold_single_instance # noqa: E402
37
+ from fabric_lock import (InstanceLock, LOCK_BACKOFF_FIRST_SECONDS, LOCK_BACKOFF_MAX_SECONDS, hold_single_instance, # noqa: E402
38
+ lock_wait_budget_seconds, supervisor_marker)
37
39
  from fabric_paths import atomic_write, instance_dirs # noqa: E402
38
40
  from fabric_share import instance_share_grant # noqa: E402
39
41
  from fabric_time import clock_ms # noqa: E402
@@ -125,9 +127,49 @@ def context_instance(service_id: str, context: Any, *, record: Optional[Dict[str
125
127
  return answer
126
128
 
127
129
 
128
- def _refuse(service_id: Any, context: Any, code: str) -> None:
129
- print("%s will not run for %s (%s): %s." % (service_id, _shown(context), code, _REASONS[code]), file=sys.stderr, flush=True)
130
- raise SystemExit(EXIT_CONTEXT_REFUSED)
130
+ def context_gate_options(info: Any, record: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
131
+ """What ``delegation_gate`` takes from this instance's context (FAA-150): ``estate_context``, and for an organization
132
+ whose context ``record`` pins, ``issuer``: the pin's context issuer (DL-14a, DEC-0128), so an organization's instance
133
+ refuses a token the personal hub key signed without wiring either by hand. ``info`` is ``context_instance``'s answer
134
+ (or ``hold_context_instance``'s); ``record`` the verified estate revision applied. Spread it after the service's own
135
+ estate issuer: ``delegation_gate(..., **{"issuer": estate_issuer, **context_gate_options(info, record)})``. Raises
136
+ ContextRefused (``context-invalid``) for an answer with no context."""
137
+ context = info.get("context") if isinstance(info, dict) else None
138
+ parsed = parse_context(context)
139
+ if parsed is None:
140
+ raise ContextRefused("context-invalid")
141
+ options: Dict[str, Any] = {"estate_context": context}
142
+ pins = record.get("organizations") if isinstance(record, dict) else None
143
+ if parsed["kind"] == "org" and isinstance(pins, list):
144
+ for pin in pins:
145
+ if isinstance(pin, dict) and pin.get("org") == context and isinstance(pin.get("iss"), str):
146
+ options["issuer"] = pin["iss"]
147
+ break
148
+ return options
149
+
150
+
151
+ def _refuse(service_id: Any, context: Any, code: str, wait_seconds: Optional[float] = 0.0, *,
152
+ sleep: Callable[[float], None] = time.sleep, clock: Callable[[], float] = time.monotonic,
153
+ env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> None:
154
+ """One sentence naming the code, then exit 78. Under a supervisor that restarts on exit (launchd's KeepAlive with
155
+ ThrottleInterval 10, systemd's Restart=always) an exit at once is a respawn every few seconds, forever (FAA-279);
156
+ so a supervised copy first waits the lock's back-off (0.5 s doubling to 30 s, 300 s in all), holding nothing, and
157
+ exits 78 when it runs out: a few restarts an hour. ``wait_seconds`` overrides the choice as the lock's does."""
158
+ if wait_seconds is None:
159
+ wait_seconds = lock_wait_budget_seconds(supervisor_marker(env, argv))
160
+ wait_seconds = max(0.0, wait_seconds)
161
+ tail = " Waiting %.0f s before exiting, so its supervisor does not restart it every few seconds." % wait_seconds \
162
+ if wait_seconds > 0 else ""
163
+ print("%s will not run for %s (%s): %s.%s" % (service_id, _shown(context), code, _REASONS[code], tail),
164
+ file=sys.stderr, flush=True)
165
+ deadline = clock() + wait_seconds
166
+ delay = LOCK_BACKOFF_FIRST_SECONDS
167
+ while True:
168
+ remaining = deadline - clock()
169
+ if remaining <= 0:
170
+ raise SystemExit(EXIT_CONTEXT_REFUSED)
171
+ sleep(min(delay, remaining))
172
+ delay = min(delay * 2, LOCK_BACKOFF_MAX_SECONDS)
131
173
 
132
174
 
133
175
  def _marker_matches(path: Path, expected: Dict[str, str]) -> Optional[bool]:
@@ -152,7 +194,7 @@ def hold_context_instance(service_id: str, context: Any, *, record: Optional[Dic
152
194
  env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None,
153
195
  services_directory: Optional[Path] = None) -> Tuple[Dict[str, Any], InstanceLock]:
154
196
  """Run as ``context``'s one instance, before any side effect (brief D17): ``context_instance`` (a refusal prints one
155
- sentence naming the code and exits 78, nothing created); then ``hold_single_instance`` on the instance's data
197
+ sentence naming the code and exits 78, nothing created; a supervised copy backs off first, FAA-279); then ``hold_single_instance`` on the instance's data
156
198
  folder (its markers, its supervised wait, its exit 75); then the marker ``<data>/fabric-context.json``: absent, it
157
199
  is written (0600, atomic); equal, it is kept; another context or instance, or unreadable, releases the lock and
158
200
  exits 78 (``context-mismatch``). Returns the instance and the held lock."""
@@ -160,7 +202,7 @@ def hold_context_instance(service_id: str, context: Any, *, record: Optional[Dic
160
202
  info = context_instance(service_id, context, record=record, machine_id=machine_id, agent_ref=agent_ref, at=at,
161
203
  personal_instance=personal_instance, services_directory=services_directory)
162
204
  except ContextRefused as refused:
163
- _refuse(service_id, context, refused.code)
205
+ _refuse(service_id, context, refused.code, wait_seconds, sleep=sleep, clock=clock, env=env, argv=argv)
164
206
  lock = hold_single_instance(info["dirs"]["data"], wait_seconds, sleep=sleep, clock=clock, env=env, argv=argv)
165
207
  expected = {"service": service_id, "instance": info["instance"], "context": context}
166
208
  marker = Path(info["dirs"]["data"]) / CONTEXT_MARKER
@@ -173,5 +215,5 @@ def hold_context_instance(service_id: str, context: Any, *, record: Optional[Dic
173
215
  raise
174
216
  if matches is False:
175
217
  lock.release()
176
- _refuse(service_id, context, "context-mismatch")
218
+ _refuse(service_id, context, "context-mismatch", wait_seconds, sleep=sleep, clock=clock, env=env, argv=argv)
177
219
  return info, lock
@@ -45,6 +45,7 @@ from fabric_jsvalue import ( # noqa: E402 - the core (FAA-65)
45
45
  jcs,
46
46
  )
47
47
  from fabric_paths import atomic_write as _atomic_write # noqa: E402 - the kit's private writer (FAA-85)
48
+ import fabric_peer_owner as _po # noqa: E402 - a loopback key set is read from this account's listener only (FAA-255)
48
49
  from fabric_mandate import effect_closure # noqa: E402,F401 - defined once beside the mandate that reads it too (DEC-0125)
49
50
 
50
51
  REFUSAL_CODES = (
@@ -672,9 +673,21 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
672
673
  return None
673
674
 
674
675
 
675
- def _opener(deadline: Dict[str, Any]) -> urllib.request.OpenerDirector:
676
+ class _CheckedHTTPConnection(_po.CheckedHTTPConnection):
677
+ """Plain http, which a key set source allows on loopback only (DEC-0154 HA-2, FAA-255): every connect, a reconnect
678
+ included, tells the server end's owner and refuses (closed, nothing sent) unless it is this OS account's process,
679
+ so keys another account's listener serves are never read; built the way urllib builds a connection."""
680
+
681
+ def __init__(self, host: str, port: Optional[int] = None, timeout: Any = 10.0, **kwargs: Any) -> None:
682
+ http.client.HTTPConnection.__init__(self, host, port, timeout=timeout, **kwargs)
683
+ self.expect_pid = None
684
+ self.vouch_pid = None # an issuer's URL is never a process this caller started (FAA-267)
685
+
686
+
687
+ def _opener(deadline: Dict[str, Any], direct: bool = False) -> urllib.request.OpenerDirector:
676
688
  """No redirect, and a timer that shuts the socket down at the deadline: a socket timeout bounds one read, so an
677
- issuer trickling a byte a second would otherwise hold the caller for as long as it likes (finding 13)."""
689
+ issuer trickling a byte a second would otherwise hold the caller for as long as it likes (finding 13). `direct`:
690
+ a loopback source never goes through a proxy from the environment, so the owner check reads the URL's own port."""
678
691
  def watched(base: type) -> type:
679
692
  class Watched(base): # type: ignore[misc, valid-type]
680
693
  def connect(self) -> None:
@@ -695,11 +708,13 @@ def _opener(deadline: Dict[str, Any]) -> urllib.request.OpenerDirector:
695
708
 
696
709
  class Http(urllib.request.HTTPHandler):
697
710
  def http_open(self, req: Any) -> Any:
698
- return self.do_open(watched(http.client.HTTPConnection), req)
711
+ return self.do_open(watched(_CheckedHTTPConnection), req) # http is loopback only (the source check)
699
712
 
700
713
  class Https(urllib.request.HTTPSHandler):
701
714
  def https_open(self, req: Any) -> Any:
702
715
  return self.do_open(watched(http.client.HTTPSConnection), req, context=self._context)
716
+ if direct:
717
+ return urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect, Http, Https)
703
718
  return urllib.request.build_opener(_NoRedirect, Http, Https)
704
719
 
705
720
 
@@ -709,7 +724,8 @@ def _fetch(source: str) -> Any:
709
724
  request = urllib.request.Request(source.partition("#")[0], headers={"Accept": "application/json"})
710
725
  deadline: Dict[str, Any] = {"end": time.monotonic() + JWKS_FETCH_SECONDS, "fired": False, "timer": None}
711
726
  try:
712
- with _opener(deadline).open(request, timeout=JWKS_FETCH_SECONDS) as response: # https or loopback http only
727
+ direct = source.lower().startswith("http://")
728
+ with _opener(deadline, direct).open(request, timeout=JWKS_FETCH_SECONDS) as response: # https or loopback http only
713
729
  body = b""
714
730
  while len(body) <= 65536 and not deadline["fired"]:
715
731
  chunk = response.read1(65537 - len(body))
@@ -724,6 +740,8 @@ def _fetch(source: str) -> Any:
724
740
  raise
725
741
  except http.client.HTTPException as exc: # a garbage status line or headers: the issuer cannot be read
726
742
  raise ConnectionError(type(exc).__name__) from None
743
+ except _po.ListenerRefused as exc: # FAA-255: another account's listener, or one that cannot be told
744
+ raise ConnectionError("foreign-user: %s" % exc) from None
727
745
  finally:
728
746
  if deadline["timer"] is not None:
729
747
  deadline["timer"].cancel()
@@ -1,5 +1,6 @@
1
1
  """How a service is found (fabric-service/0.1): its descriptor, peers and app descriptors (DEC-0061, DEC-0069),
2
2
  build identity and the well-known document, the loopback HTTP server. Part of the service kit (FAA-65, layer L3)."""
3
+ # Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
3
4
  from __future__ import annotations
4
5
 
5
6
  import hashlib
@@ -7,6 +8,7 @@ import http.server
7
8
  import json
8
9
  import os
9
10
  import re
11
+ import socket
10
12
  import socketserver
11
13
  import subprocess
12
14
  import warnings
@@ -18,6 +20,7 @@ from pathlib import Path as _Path # noqa: E402
18
20
  if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
19
21
  _sys.path.insert(0, str(_Path(__file__).resolve().parent))
20
22
  from fabric_base import (
23
+ PRIMARY_CAPABILITY,
21
24
  PROTOCOL,
22
25
  ServiceError,
23
26
  _ID,
@@ -28,6 +31,7 @@ from fabric_base import (
28
31
  expand,
29
32
  now_iso,
30
33
  )
34
+ import fabric_peer_owner # noqa: E402 - L0: who listens on a port (DEC-0154 HA-2, FAA-225)
31
35
  from fabric_paths import (
32
36
  atomic_write,
33
37
  ensure_private_dir,
@@ -49,7 +53,7 @@ from fabric_usage import (
49
53
  DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
50
54
 
51
55
 
52
- DASHBOARD_KIT = "fabric-dashboard-kit@1.1.5"
56
+ DASHBOARD_KIT = "fabric-dashboard-kit@1.1.6"
53
57
 
54
58
 
55
59
  REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
@@ -476,8 +480,35 @@ def source_refusal(descriptor: Dict[str, Any], directory: Optional[Path] = None)
476
480
  return "Descriptor %s %s. %s" % (me, NO_SOURCE_REPOSITORY, PASS_SOURCE)
477
481
 
478
482
 
479
- def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = None, *, _stacklevel: int = 2) -> Path:
480
- """Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim).
483
+ def port_owner(port: int) -> Optional[Tuple[str, Optional[int]]]:
484
+ """Who listens on 127.0.0.1:<port> now, as (kind, uid) from the kit's fabric_peer_owner, or None when a plain bind
485
+ of that address succeeds (nothing holds it, and the socket table is not read). FAA-225, DEC-0154 HA-2."""
486
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
487
+ try:
488
+ probe.bind(("127.0.0.1", port))
489
+ return None
490
+ except OSError:
491
+ pass
492
+ owner = fabric_peer_owner.listener(port)
493
+ return owner.kind, owner.uid
494
+
495
+
496
+ def port_listener_refusal(port: int, reader: Optional[Any] = None) -> Optional[str]:
497
+ """Why an install may not take ``port``: another OS account listens on it (FAA-225). This account, the system
498
+ (launchd holds a socket-activated job's socket), nobody and an unreadable table pass: the refusal is for a port
499
+ that is surely another account's. The recorded port per account and its claim file wait on contract DEC-0160."""
500
+ found = (reader or port_owner)(port)
501
+ if not found or found[0] != fabric_peer_owner.ANOTHER_ACCOUNT:
502
+ return None
503
+ uid = " (uid %d)" % found[1] if isinstance(found[1], int) else ""
504
+ return ("Port %d is listened on by another OS account%s: choose a port no other account holds (DEC-0154, "
505
+ "FAA-225)." % (port, uid))
506
+
507
+
508
+ def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = None, *, _stacklevel: int = 2,
509
+ port_owner: Optional[Any] = None) -> Path:
510
+ """Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim), and a port another
511
+ OS account listens on (FAA-225; ``port_owner(port) -> (kind, uid) | None`` reads it, the kit's own by default).
481
512
 
482
513
  FAA-198: a new descriptor names its ``source.repository``; rewriting one already installed without it only
483
514
  warns (``NoSourceRepositoryWarning``), so an installed agent's update keeps working."""
@@ -504,6 +535,9 @@ def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = Non
504
535
  continue
505
536
  if other_port == port:
506
537
  raise ServiceError("Port %d is already claimed by %s (%s)." % (port, key, path))
538
+ held = port_listener_refusal(port, port_owner) if port is not None else None
539
+ if held:
540
+ raise ServiceError(held)
507
541
  ensure_private_dir(root)
508
542
  target = descriptor_path(descriptor["id"], descriptor["instance"], root)
509
543
  atomic_write(target, (json.dumps(descriptor, indent=2, ensure_ascii=False) + "\n").encode(), 0o600)
@@ -628,6 +662,11 @@ def configuration_problems(doc: Any) -> List[str]:
628
662
  return problems
629
663
 
630
664
 
665
+ #: FAA-244: DEC-0140 PR-1 reserves the primary responder's capability; a service never publishes it.
666
+ _RESERVED = ("%%s: %s is reserved for a Project's primary responder, a session (DEC-0140 PR-1); a service never "
667
+ "serves it as a tool or declares it." % PRIMARY_CAPABILITY)
668
+
669
+
631
670
  def build_well_known(*, service_id: str, instance: str, name: str, version: str, build: Dict[str, Any],
632
671
  started_at: str, status: str, degraded: Sequence[Dict[str, str]],
633
672
  surfaces: Dict[str, Any], summary: Optional[Sequence[Dict[str, Any]]] = None,
@@ -664,6 +703,10 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
664
703
  problems = a2a_surface_problems(surfaces["a2a"])
665
704
  if problems:
666
705
  raise ServiceError("surfaces.a2a: %s." % "; ".join(problems))
706
+ mcp_surface = surfaces.get("mcp")
707
+ listed = mcp_surface.get("capabilities") if isinstance(mcp_surface, dict) else None
708
+ if isinstance(listed, list) and PRIMARY_CAPABILITY in listed:
709
+ raise ServiceError(_RESERVED % "surfaces.mcp.capabilities")
667
710
  doc: Dict[str, Any] = {
668
711
  "protocol": PROTOCOL,
669
712
  "service": {"id": service_id, "instance": instance, "name": name, "version": version, "build": build},
@@ -698,6 +741,9 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
698
741
  "surfaces": surfaces})
699
742
  if findings:
700
743
  raise ServiceError("Actions declaration: %s." % "; ".join(f["message"] for f in findings))
744
+ if any(isinstance(a, dict) and a.get("tool") == PRIMARY_CAPABILITY
745
+ for a in extensions[ACTIONS_EXTENSION_KEY].get("actions") or []):
746
+ raise ServiceError(_RESERVED % "an action's tool")
701
747
  doc["extensions"] = dict(extensions)
702
748
  if configuration is not None:
703
749
  problems = configuration_problems(configuration)
@@ -28,6 +28,7 @@ The transport is injected in tests: `transport(method, url, headers, body, timeo
28
28
  `(status, headers, body_bytes)` or raises `OSError` (a connection error, `TimeoutError` included) or `HttpError`.
29
29
  Standard library only. Part of the service kit (FAA-65, layer L4).
30
30
  """
31
+ # Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
31
32
  from __future__ import annotations
32
33
 
33
34
  import datetime as _dt
@@ -64,7 +65,7 @@ class HttpError(Exception):
64
65
  refused URL or a body over the cap; `url` is redacted; `body` holds at most 2048 bytes of an answer's body."""
65
66
 
66
67
  def __init__(self, message: str, *, status: Optional[int] = None, url: str = "", attempts: int = 0,
67
- retry_after_s: Optional[float] = None, body: bytes = b"", kind: str = "http"):
68
+ retry_after_s: Optional[float] = None, body: bytes = b"", kind: str = "http", reason: Optional[str] = None):
68
69
  super().__init__(message)
69
70
  self.status = status
70
71
  self.url = url
@@ -72,6 +73,9 @@ class HttpError(Exception):
72
73
  self.retry_after_s = retry_after_s
73
74
  self.body = bytes(body[:2048])
74
75
  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
+ self.reason = reason
75
79
 
76
80
 
77
81
  class Response:
@@ -385,7 +389,10 @@ def urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optio
385
389
  return _send(req, timeout_s, max_bytes, end, watchdog)
386
390
  except _po.ListenerRefused as exc:
387
391
  where = "%s %s" % (method, redact_url(url))
388
- raise HttpError("%s: foreign-user: %s" % (where, exc), url=redact_url(url), kind="foreign-user") from None
392
+ owner = getattr(exc, "owner", None)
393
+ why = str(getattr(owner, "reason", None) or getattr(owner, "kind", None) or getattr(exc, "code", "") or "unknown")
394
+ reason = _po.TABLE_RESTRICTED if why.startswith(_po.TABLE_RESTRICTED) else why.split(":", 1)[0]
395
+ raise HttpError("%s: foreign-user: %s" % (where, exc), url=redact_url(url), kind="foreign-user", reason=reason) from None
389
396
  except (OSError, http.client.HTTPException) as exc:
390
397
  if watchdog.fired:
391
398
  raise TimeoutError("the answer did not arrive within the deadline")
@@ -67,6 +67,20 @@ class HubError(Exception):
67
67
  return "HubError(%r, %r)" % (self.code, self.message)
68
68
 
69
69
 
70
+ def listener_refusal(exc: Any) -> HubError:
71
+ """The HubError for a hub port the peer-owner check refused (DEC-0154 HA-2): code `foreign-user`, the pinned
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."""
75
+ owner = getattr(exc, "owner", None)
76
+ why = (getattr(owner, "reason", None) or getattr(owner, "kind", None) or getattr(exc, "code", None) or "unknown")
77
+ reason = _po.TABLE_RESTRICTED if str(why).startswith(_po.TABLE_RESTRICTED) else str(why).split(":", 1)[0]
78
+ kind = getattr(owner, "kind", None)
79
+ shown = "%s: %s" % (kind, owner.reason) if kind and getattr(owner, "reason", None) else str(kind or why)
80
+ return HubError("foreign-user", "The hub's port is not held by this OS account's process (%s); nothing was sent." % shown,
81
+ reason != _po.TABLE_RESTRICTED, {"reason": reason})
82
+
83
+
70
84
  # --- where Fabric is --------------------------------------------------------------------------------------------
71
85
 
72
86
  def fabric_root(platform: str = sys.platform, env: Optional[Dict[str, str]] = None) -> Path:
@@ -233,8 +247,7 @@ def post_mcp(origin: str, body: Dict[str, Any], bearer: str, session: Optional[s
233
247
  raw = resp.read(4 * 1024 * 1024)
234
248
  got = {k.lower(): v for k, v in resp.getheaders()}
235
249
  except _po.ListenerRefused as exc:
236
- raise HubError("foreign-user", "The hub's port is not held by this OS account's process (%s); nothing was sent."
237
- % (exc.owner.kind if exc.owner else exc.code), True) from None
250
+ raise listener_refusal(exc) from None
238
251
  except (OSError, http.client.HTTPException) as exc:
239
252
  raise HubError("hub-unavailable", "Fabric's hub did not answer (%s)." % exc.__class__.__name__, True) from None
240
253
  finally:
@@ -825,7 +825,7 @@ class McpToolServer:
825
825
  gated = self._gate_action(action, "fabric.job.get", {"id": job_id, "inputResponses": {key: value}}, None, None,
826
826
  headers, tier, meta, caller, mandate)
827
827
  if not gated["ok"]:
828
- return gated
828
+ return dict(gated, capability=private.get("capability")) # the one read names the fault (FAA-186)
829
829
  records[key] = gated["record"]
830
830
  return {"ok": True, "records": records}
831
831
 
@@ -933,7 +933,7 @@ class McpToolServer:
933
933
  if not inputs["ok"] and "invalid" in inputs:
934
934
  return self._error(rid, -32602, inputs["invalid"])
935
935
  if not inputs["ok"]:
936
- stored = self.jobs.private(str(arguments.get("id", ""))).get("capability")
936
+ stored = inputs.get("capability") # read once, by _gate_inputs (FAA-186)
937
937
  self._fault(stored if isinstance(stored, str) else name, inputs) # as the start heard it (FAA-179)
938
938
  import fabric_delegation as _fd # noqa: E402 - DL-19
939
939
  return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(inputs["refusal"], traceparent=span)}