@passioncode-ai/fabric-agent-adapter 0.79.1 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +13 -2
  3. package/SECURITY.md +23 -8
  4. package/bin/fabric-agent-adapter.js +50 -5
  5. package/package.json +1 -1
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.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 +12 -4
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +1 -1
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +107 -2
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +26 -6
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +9 -1
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +24 -5
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -76
  17. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
  18. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +912 -0
  19. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +8 -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/recipes/support-agent/README.md +1 -1
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +12 -4
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +7 -3
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +54 -3
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -3
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +33 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -7
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +26 -6
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +24 -33
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +12 -6
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +136 -3
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +3 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +1 -0
  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-descriptor.mjs +118 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +266 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +72 -7
  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-package.mjs +98 -1
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +7 -3
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +10 -3
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +11 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +114 -20
  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_board_session.py +3 -2
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +120 -3
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +316 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +40 -3
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +28 -13
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +24 -5
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +23 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -9
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +469 -29
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
  80. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +18 -6
  81. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
  82. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
  83. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
  84. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +12 -4
  85. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
  86. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
  87. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -1
  88. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
  89. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +28 -0
  90. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
  91. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
  92. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
  93. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
  94. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
  95. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
  96. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +26 -6
  97. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
  98. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
  99. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
  100. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
  101. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
  102. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
  103. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
  104. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
  105. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
  106. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  107. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +21 -21
  108. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
  109. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
  110. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +2 -1
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +3 -2
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +143 -36
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +136 -15
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
@@ -1,14 +1,18 @@
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
6
7
  import http.server
8
+ import ipaddress
7
9
  import json
8
10
  import os
9
11
  import re
12
+ import socket
10
13
  import socketserver
11
14
  import subprocess
15
+ import threading
12
16
  import warnings
13
17
  from pathlib import Path
14
18
  from typing import Any, Dict, List, Optional, Sequence, Tuple
@@ -29,6 +33,7 @@ from fabric_base import (
29
33
  expand,
30
34
  now_iso,
31
35
  )
36
+ import fabric_peer_owner # noqa: E402 - L0: who listens on a port (DEC-0154 HA-2, FAA-225)
32
37
  from fabric_paths import (
33
38
  atomic_write,
34
39
  ensure_private_dir,
@@ -85,19 +90,101 @@ _NOT_LAUNCHD = ("systemd", "task-scheduler", "node", "person")
85
90
  _AUTH_HEADER = re.compile(r"[A-Za-z][A-Za-z0-9-]{0,63}")
86
91
 
87
92
 
93
+ #: How a loopback server treats the OS account of the process that connected (DEC-0154 HA-2's server side, HA-3;
94
+ #: FAA-227): `refuse` (the default) closes a connection whose peer is another OS account's before reading a byte of
95
+ #: it and serves the rest, labelled; `strict` also refuses a peer whose owner cannot be told and a system account
96
+ #: other than root (uid 0, which can read every account's files and installs a LaunchDaemon); `off` reads no table.
97
+ PEER_CHECKS = ("refuse", "strict", "off")
98
+
99
+
100
+ def connection_refusal(owner: Any, mode: str = "refuse") -> Optional[str]:
101
+ """Why a server refuses the peer `owner` (a fabric_peer_owner.Owner) under `mode`, or None to serve it. The reason
102
+ is the owner's kind, or for `unknown` its reason; it names no address and nothing the peer sent."""
103
+ if mode not in PEER_CHECKS:
104
+ raise ValueError("peer_check must be one of %s" % ", ".join(PEER_CHECKS))
105
+ if mode == "off" or owner is None:
106
+ return None
107
+ kind = getattr(owner, "kind", fabric_peer_owner.UNKNOWN)
108
+ if kind == fabric_peer_owner.ANOTHER_ACCOUNT:
109
+ return kind
110
+ if mode == "strict" and kind != fabric_peer_owner.THIS_ACCOUNT:
111
+ if kind == fabric_peer_owner.SYSTEM and getattr(owner, "uid", None) == 0:
112
+ return None
113
+ return getattr(owner, "reason", None) or kind
114
+ return None
115
+
116
+
117
+ def is_loopback_peer(client_address: Any) -> bool:
118
+ """Whether an accepted connection's peer address is a loopback one (127.0.0.0/8, ::1, or either IPv4-mapped): the
119
+ only peers whose owner can be another account of this computer (DEC-0154 HA-2). An address that cannot be read
120
+ counts as loopback, so its owner is still read from the table."""
121
+ try:
122
+ return ipaddress.ip_address(fabric_peer_owner.norm_host(str(client_address[0]))).is_loopback
123
+ except Exception:
124
+ return True
125
+
126
+
88
127
  class LoopbackHTTPServer(http.server.ThreadingHTTPServer):
89
128
  """ThreadingHTTPServer for a loopback service: threads are daemons, and the bind asks
90
129
  no resolver. HTTPServer.server_bind() calls socket.getfqdn() between bind() and listen();
91
130
  on a Mac with a slow resolver the port then stays bound but silent, so a host sees
92
- neither an answer nor a refusal."""
131
+ neither an answer nor a refusal.
132
+
133
+ Each accepted connection's peer is told from the computer's socket table (`fabric_peer_owner.peer`) in the
134
+ connection's own thread, before the handler reads a byte (DEC-0154 HA-2's server side, FAA-227). `peer_check`
135
+ (`PEER_CHECKS`) decides which owners are closed unread; a served handler finds its peer's owner with
136
+ `self.server.peer_owner()`. A refusal writes one line to stderr with the owner's kind, uid and pid and nothing the
137
+ peer sent (`peer_refused`), and counts in `peer_refusals`. The check applies to loopback peers only: a peer at
138
+ another address (a service bound to 0.0.0.0) is no local account, so its table is not read and it is served under
139
+ `refuse` and `strict` alike, with `peer_owner()` None."""
93
140
 
94
141
  daemon_threads = True
142
+ peer_check = "refuse"
143
+ #: The reader of an accepted socket's peer; a test replaces it.
144
+ peer_reader = staticmethod(lambda sock: fabric_peer_owner.peer(sock))
145
+
146
+ def __init__(self, server_address: Any, handler: Any, bind_and_activate: bool = True, *,
147
+ peer_check: Optional[str] = None) -> None:
148
+ mode = self.peer_check if peer_check is None else peer_check
149
+ connection_refusal(None, mode) # refuses an unknown mode before the port is bound
150
+ self.peer_check = mode
151
+ self.peer_refusals = 0
152
+ self._peer = threading.local()
153
+ super().__init__(server_address, handler, bind_and_activate)
95
154
 
96
155
  def server_bind(self) -> None:
97
156
  socketserver.TCPServer.server_bind(self)
98
157
  host, port = self.server_address[:2]
99
158
  self.server_name, self.server_port = host, port
100
159
 
160
+ def peer_owner(self) -> Any:
161
+ """The owner of the peer this thread's handler serves (None under `peer_check="off"`)."""
162
+ return getattr(self._peer, "owner", None)
163
+
164
+ def finish_request(self, request: Any, client_address: Any) -> None:
165
+ owner = None
166
+ if self.peer_check != "off" and is_loopback_peer(client_address):
167
+ try:
168
+ owner = self.peer_reader(request)
169
+ except Exception as exc: # a reader that fails is an owner nobody can tell, never a crash of the server
170
+ owner = fabric_peer_owner.Owner(fabric_peer_owner.UNKNOWN, None, None, "none",
171
+ "reader: %s" % type(exc).__name__)
172
+ reason = connection_refusal(owner, self.peer_check)
173
+ if reason is not None:
174
+ self.peer_refusals += 1
175
+ self.peer_refused(owner, reason)
176
+ return # process_request_thread closes the socket unread (shutdown_request)
177
+ self._peer.owner = owner
178
+ try:
179
+ super().finish_request(request, client_address)
180
+ finally:
181
+ self._peer.owner = None
182
+
183
+ def peer_refused(self, owner: Any, reason: str) -> None:
184
+ """One line on stderr per refused connection; override to log it elsewhere. Never the peer's bytes."""
185
+ _sys.stderr.write("fabric: refused a loopback peer (%s, uid %s, pid %s) before reading it (DEC-0154)\n"
186
+ % (reason, getattr(owner, "uid", None), getattr(owner, "pid", None)))
187
+
101
188
 
102
189
  def descriptor_path(service_id: str, instance: str = "default", directory: Optional[Path] = None) -> Path:
103
190
  return (directory or services_dir()) / ("%s.%s.json" % (service_id, instance))
@@ -477,8 +564,35 @@ def source_refusal(descriptor: Dict[str, Any], directory: Optional[Path] = None)
477
564
  return "Descriptor %s %s. %s" % (me, NO_SOURCE_REPOSITORY, PASS_SOURCE)
478
565
 
479
566
 
480
- def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = None, *, _stacklevel: int = 2) -> Path:
481
- """Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim).
567
+ def port_owner(port: int) -> Optional[Tuple[str, Optional[int]]]:
568
+ """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
569
+ of that address succeeds (nothing holds it, and the socket table is not read). FAA-225, DEC-0154 HA-2."""
570
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
571
+ try:
572
+ probe.bind(("127.0.0.1", port))
573
+ return None
574
+ except OSError:
575
+ pass
576
+ owner = fabric_peer_owner.listener(port)
577
+ return owner.kind, owner.uid
578
+
579
+
580
+ def port_listener_refusal(port: int, reader: Optional[Any] = None) -> Optional[str]:
581
+ """Why an install may not take ``port``: another OS account listens on it (FAA-225). This account, the system
582
+ (launchd holds a socket-activated job's socket), nobody and an unreadable table pass: the refusal is for a port
583
+ that is surely another account's. The recorded port per account and its claim file wait on contract DEC-0160."""
584
+ found = (reader or port_owner)(port)
585
+ if not found or found[0] != fabric_peer_owner.ANOTHER_ACCOUNT:
586
+ return None
587
+ uid = " (uid %d)" % found[1] if isinstance(found[1], int) else ""
588
+ return ("Port %d is listened on by another OS account%s: choose a port no other account holds (DEC-0154, "
589
+ "FAA-225)." % (port, uid))
590
+
591
+
592
+ def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = None, *, _stacklevel: int = 2,
593
+ port_owner: Optional[Any] = None) -> Path:
594
+ """Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim), and a port another
595
+ OS account listens on (FAA-225; ``port_owner(port) -> (kind, uid) | None`` reads it, the kit's own by default).
482
596
 
483
597
  FAA-198: a new descriptor names its ``source.repository``; rewriting one already installed without it only
484
598
  warns (``NoSourceRepositoryWarning``), so an installed agent's update keeps working."""
@@ -505,6 +619,9 @@ def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = Non
505
619
  continue
506
620
  if other_port == port:
507
621
  raise ServiceError("Port %d is already claimed by %s (%s)." % (port, key, path))
622
+ held = port_listener_refusal(port, port_owner) if port is not None else None
623
+ if held:
624
+ raise ServiceError(held)
508
625
  ensure_private_dir(root)
509
626
  target = descriptor_path(descriptor["id"], descriptor["instance"], root)
510
627
  atomic_write(target, (json.dumps(descriptor, indent=2, ensure_ascii=False) + "\n").encode(), 0o600)
@@ -0,0 +1,316 @@
1
+ """Work a service holds by its own limits, said to its caller and its operator (FAA-298). Part of the service kit
2
+ (layer L3, after fabric_interop).
3
+
4
+ A service that holds work by a limit of its own (a daily quota, a per-caller share, a concurrency cap, a money cap)
5
+ says so three ways:
6
+
7
+ - to the caller, on the job: `waiting_message(reason, since, until, message)` is the job's `statusMessage`. The pinned
8
+ `interop-job` schema is closed (`additionalProperties: false`), so `waiting {reason, since, until, message}` cannot
9
+ be a field of the job; it rides the contract's pause grammar (DEC-0087) as its text:
10
+ `quota-exhausted[ until <until>]: waiting <reason> since <since>[: <message>]`. A caller that reads only the pause
11
+ grammar waits (and until `until`, when given); `read_waiting(answer)` gives the four parts back. `JobStore.pause`
12
+ takes the same statusMessage through `hold_job`.
13
+ - to the operator, once per hold: `HoldBook.hold` opens a hold per (reason, caller), or joins the open one, and returns
14
+ the push only when it opens: one `notify` event naming the caller, the work, the reason and when the hold frees by
15
+ itself, with the question whether to raise the limit. Further jobs held by the same limit for the same caller join
16
+ the hold silently; a hold is closed by `release`, by its `until` passing, or by a lift that covers it.
17
+ - the answer, as a scoped lift: `HoldBook.lift` records the operator's words (a DEC-0121 decision, action
18
+ `limit.lift`) with its scope (the reason, optionally one caller and one job) and its size (a number of jobs, an
19
+ amount of money, or a raised limit for a time window such as 100 a day for today). `admit` spends a jobs or money
20
+ lift; `limit` gives the limit in force. A lift falls back by itself when its window ends or its amount is spent.
21
+
22
+ `HoldBook` keeps one JSON file (0600, written atomically); a ThreadingHTTPServer's handlers share it under the store
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.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import re
30
+ import uuid
31
+ from pathlib import Path
32
+ from typing import Any, Callable, Dict, List, Optional
33
+
34
+ import sys as _sys # noqa: E402
35
+ from pathlib import Path as _Path # noqa: E402
36
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
37
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
38
+ from fabric_base import ServiceError, now_iso, store_lock # noqa: E402
39
+ from fabric_paths import atomic_write # noqa: E402
40
+ from fabric_actions import decision_shape # noqa: E402
41
+ from fabric_interop_rules import pause_message, read_job_pause, utc_time, QUOTA_EXHAUSTED # noqa: E402
42
+ import fabric_secrets # noqa: E402
43
+
44
+ #: The limits a hold names: a daily quota, a per-caller share, a concurrency cap, a money cap.
45
+ HOLD_REASONS = ("daily-quota", "caller-share", "concurrency", "money")
46
+ #: How the push names each reason to a person.
47
+ REASON_WORDS = {"daily-quota": "the daily quota", "caller-share": "its per-caller share",
48
+ "concurrency": "the concurrency cap", "money": "the money cap"}
49
+ #: The push's kind: a decision for the operator (level notice, notify true; events-and-notifications.md).
50
+ HOLD_EVENT_KIND = "limit.held"
51
+ #: The record of a lift applied (level info, no notify).
52
+ LIFT_EVENT_KIND = "limit.lifted"
53
+ #: The decision action a lift carries (DEC-0121 AP-2 shape).
54
+ LIFT_ACTION = "limit.lift"
55
+ _MAX_STATUS_MESSAGE = 500
56
+ _WAITING = re.compile(r"^waiting (daily-quota|caller-share|concurrency|money) since ([0-9][0-9TZ:.-]*?)(?:: ([\s\S]+))?\Z")
57
+ _SIZES = ("jobs", "usd", "limit")
58
+
59
+
60
+ def _instant(value: Any, name: str) -> str:
61
+ if utc_time(value) is None:
62
+ raise ServiceError("%s is a UTC instant (Z, at most 3 fraction digits)." % name)
63
+ return value
64
+
65
+
66
+ def waiting_message(reason: str, since: str, until: Optional[str] = None, message: Optional[str] = None) -> str:
67
+ """A held job's statusMessage: the pause grammar, its text `waiting <reason> since <since>[: <message>]`. The
68
+ message is shortened, never refused, to fit the contract's 500 characters; a reason or an instant outside the
69
+ grammar raises ServiceError."""
70
+ if reason not in HOLD_REASONS:
71
+ raise ServiceError("A hold's reason is one of %s." % ", ".join(HOLD_REASONS))
72
+ _instant(since, "since")
73
+ if until is not None:
74
+ _instant(until, "until")
75
+ text = "waiting %s since %s" % (reason, since)
76
+ head = len(QUOTA_EXHAUSTED) + (0 if until is None else len(" until ") + len(until)) + len(": ")
77
+ if message is not None:
78
+ message = " ".join(str(message).split())
79
+ room = _MAX_STATUS_MESSAGE - head - len(text) - len(": ")
80
+ if message and room > 0:
81
+ text += ": " + (message if len(message) <= room else message[:room - 3].rstrip() + "...")
82
+ return pause_message(until, text)
83
+
84
+
85
+ def read_waiting(answer: Any) -> Optional[Dict[str, Any]]:
86
+ """`{reason, since, until?, message?}` of a fabric.job.get answer whose job a limit holds (`waiting_message`), else
87
+ None: a plain pause, a queue position or a finished job is not a hold."""
88
+ pause = read_job_pause(answer)
89
+ if not pause.get("paused"):
90
+ return None
91
+ status = answer["job"]["statusMessage"]
92
+ rest = status[len(QUOTA_EXHAUSTED):]
93
+ if "resetsAt" in pause:
94
+ rest = rest[len(" until ") + len(pause["resetsAt"]):]
95
+ if not rest.startswith(": "):
96
+ return None
97
+ match = _WAITING.match(rest[2:])
98
+ if not match or utc_time(match.group(2)) is None:
99
+ return None
100
+ waiting: Dict[str, Any] = {"reason": match.group(1), "since": match.group(2)}
101
+ if "resetsAt" in pause:
102
+ waiting["until"] = pause["resetsAt"]
103
+ if match.group(3) is not None:
104
+ waiting["message"] = match.group(3)
105
+ return waiting
106
+
107
+
108
+ def hold_job(jobs: Any, job_id: str, reason: str, since: str, until: Optional[str] = None,
109
+ message: Optional[str] = None) -> Dict[str, Any]:
110
+ """Put one job of a fabric_interop JobStore on hold: working, its statusMessage `waiting_message(...)`, its
111
+ pollIntervalMs reaching `until` (JobStore.pause). Returns the job."""
112
+ text = waiting_message(reason, since, until, message)[len(QUOTA_EXHAUSTED):]
113
+ if until is not None:
114
+ text = text[len(" until ") + len(until):]
115
+ return jobs.pause(job_id, resets_at=until, text=text[2:])
116
+
117
+
118
+ def hold_sentence(caller: str, work: str, reason: str, until: Optional[str]) -> str:
119
+ """The push's one sentence: who, what, why, when it frees by itself, and the question."""
120
+ frees = ("it frees by itself at %s" % until) if until is not None else (
121
+ "it frees by itself when a running job ends" if reason in ("concurrency", "caller-share")
122
+ else "it does not free by itself")
123
+ return "%s for %s is held by %s; %s. Raise the limit for it?" % (work, caller, REASON_WORDS[reason], frees)
124
+
125
+
126
+ def _text(value: Any, name: str, high: int = 200) -> str:
127
+ if not isinstance(value, str) or not value.strip() or len(value) > high:
128
+ raise ServiceError("%s is a text of 1 to %d characters." % (name, high))
129
+ if fabric_secrets.secret_value(value):
130
+ raise ServiceError("%s looks like a credential; a hold names callers and work, never a secret." % name)
131
+ return " ".join(value.split())
132
+
133
+
134
+ class HoldBook:
135
+ """The holds a service's limits put on work and the operator's lifts, in one file (FAA-298).
136
+
137
+ `events`, when given, is the service's event log (an object with `append(kind, level, text, subject=..., **extra)`,
138
+ such as fabric_events.JsonlEventLog): the push and the lift record are written there. Without it they are returned
139
+ for the service to write."""
140
+
141
+ def __init__(self, path: Path, events: Any = None, clock: Callable[[], str] = now_iso):
142
+ self.path = Path(path)
143
+ self.events = events
144
+ self.clock = clock
145
+ self._lock = store_lock(self.path)
146
+
147
+ # --- the file -------------------------------------------------------------------------
148
+ def _load(self) -> Dict[str, Any]:
149
+ if not self.path.exists():
150
+ return {"holds": [], "lifts": []}
151
+ try:
152
+ data = json.loads(self.path.read_text(encoding="utf-8"))
153
+ except ValueError:
154
+ raise ServiceError("The hold book %s is not JSON; it is kept as it is." % self.path.name) from None
155
+ if not isinstance(data, dict) or not isinstance(data.get("holds"), list) or not isinstance(data.get("lifts"), list):
156
+ raise ServiceError("The hold book %s is not {holds, lifts}; it is kept as it is." % self.path.name)
157
+ return data
158
+
159
+ def _save(self, data: Dict[str, Any]) -> None:
160
+ atomic_write(self.path, (json.dumps(data, ensure_ascii=False, indent=1) + "\n").encode(), 0o600)
161
+
162
+ def _now(self, now: Optional[str]) -> str:
163
+ return _instant(now if now is not None else self.clock(), "now")
164
+
165
+ def _emit(self, kind: str, level: str, text: str, subject: Dict[str, str], notify: bool) -> Dict[str, Any]:
166
+ if self.events is not None:
167
+ return self.events.append(kind, level, text, subject=subject, notify=notify)
168
+ out: Dict[str, Any] = {"kind": kind, "level": level, "text": text, "subject": subject}
169
+ if notify:
170
+ out["notify"] = True
171
+ return out
172
+
173
+ @staticmethod
174
+ def _expire(data: Dict[str, Any], at: int) -> None:
175
+ for hold in data["holds"]:
176
+ if hold.get("closed") is None and hold.get("until") is not None and utc_time(hold["until"]) <= at:
177
+ hold["closed"], hold["closedAt"] = "expired", hold["until"]
178
+
179
+ @staticmethod
180
+ def _live(lift: Dict[str, Any], at: int) -> bool:
181
+ if lift.get("from") is not None and utc_time(lift["from"]) > at:
182
+ return False
183
+ if lift.get("until") is not None and utc_time(lift["until"]) <= at:
184
+ return False
185
+ return lift.get("left", 1) > 0
186
+
187
+ # --- holds ----------------------------------------------------------------------------
188
+ def hold(self, *, caller: str, work: str, reason: str, until: Optional[str] = None, message: Optional[str] = None,
189
+ job_id: Optional[str] = None, now: Optional[str] = None) -> Dict[str, Any]:
190
+ """Hold `work` for `caller` by `reason`. Returns {hold, statusMessage, push}: `push` is the notify event when this
191
+ call opened the hold, None when the work joined an open one (one push per hold)."""
192
+ caller, work = _text(caller, "caller"), _text(work, "work")
193
+ if reason not in HOLD_REASONS:
194
+ raise ServiceError("A hold's reason is one of %s." % ", ".join(HOLD_REASONS))
195
+ at = self._now(now)
196
+ if until is not None and utc_time(_instant(until, "until")) <= utc_time(at):
197
+ raise ServiceError("until %s has passed: the limit no longer holds the work." % until)
198
+ with self._lock:
199
+ data = self._load()
200
+ self._expire(data, utc_time(at))
201
+ hold = next((h for h in data["holds"] if h.get("closed") is None and h["reason"] == reason
202
+ and h["caller"] == caller), None)
203
+ opened = hold is None
204
+ if opened:
205
+ hold = {"id": uuid.uuid4().hex, "reason": reason, "caller": caller, "work": work, "since": at,
206
+ "until": until, "jobs": [], "closed": None}
207
+ data["holds"].append(hold)
208
+ if job_id is not None and job_id not in hold["jobs"]:
209
+ hold["jobs"].append(str(job_id))
210
+ self._save(data)
211
+ status = waiting_message(reason, hold["since"], hold["until"], message)
212
+ push = None
213
+ if opened:
214
+ push = self._emit(HOLD_EVENT_KIND, "notice", hold_sentence(caller, work, reason, until),
215
+ {"type": "limit", "id": "%s:%s" % (reason, caller)[:200], "label": REASON_WORDS[reason]}, True)
216
+ return {"hold": dict(hold), "statusMessage": status, "push": push}
217
+
218
+ def release(self, *, reason: str, caller: str, now: Optional[str] = None) -> Optional[Dict[str, Any]]:
219
+ """Close the open hold of (reason, caller): the limit freed the work. The next hold pushes again."""
220
+ at = self._now(now)
221
+ with self._lock:
222
+ data = self._load()
223
+ hold = next((h for h in data["holds"] if h.get("closed") is None and h["reason"] == reason
224
+ and h["caller"] == caller), None)
225
+ if hold is None:
226
+ return None
227
+ hold["closed"], hold["closedAt"] = "released", at
228
+ self._save(data)
229
+ return dict(hold)
230
+
231
+ def open_holds(self, now: Optional[str] = None) -> List[Dict[str, Any]]:
232
+ data = self._load()
233
+ self._expire(data, utc_time(self._now(now)))
234
+ return [dict(h) for h in data["holds"] if h.get("closed") is None]
235
+
236
+ # --- lifts ----------------------------------------------------------------------------
237
+ def lift(self, *, reason: str, decision: Dict[str, Any], caller: Optional[str] = None, job_id: Optional[str] = None,
238
+ jobs: Optional[int] = None, usd: Optional[float] = None, limit: Optional[int] = None,
239
+ until: Optional[str] = None, now: Optional[str] = None) -> Dict[str, Any]:
240
+ """Record the operator's answer as a scoped lift: exactly one of `jobs` (that many more jobs), `usd` (that much
241
+ more money) or `limit` (the limit raised to that value, which needs `until`, the window's end). `decision` is
242
+ the operator's words in decision.schema.json's shape with action `limit.lift`. The open hold it covers closes
243
+ as lifted. Returns the lift."""
244
+ if reason not in HOLD_REASONS:
245
+ raise ServiceError("A lift's reason is one of %s." % ", ".join(HOLD_REASONS))
246
+ if not decision_shape(decision) or decision.get("action") != LIFT_ACTION:
247
+ raise ServiceError("A lift carries the operator's decision (decision.schema.json, action limit.lift, their "
248
+ "words verbatim): fabric_actions.operator_decision('limit.lift', words).")
249
+ sizes = {"jobs": jobs, "usd": usd, "limit": limit}
250
+ given = [name for name in _SIZES if sizes[name] is not None]
251
+ if len(given) != 1:
252
+ raise ServiceError("A lift is one of jobs, usd or limit.")
253
+ size = sizes[given[0]]
254
+ if isinstance(size, bool) or not isinstance(size, (int, float)) or size <= 0 or (
255
+ given[0] != "usd" and int(size) != size):
256
+ raise ServiceError("A lift's %s is a positive %s." % (given[0], "number" if given[0] == "usd" else "integer"))
257
+ at = self._now(now)
258
+ if until is not None and utc_time(_instant(until, "until")) <= utc_time(at):
259
+ raise ServiceError("A lift's window ends after now.")
260
+ if given[0] == "limit" and until is None:
261
+ raise ServiceError("A raised limit needs its window's end (until): it falls back when the window ends.")
262
+ record: Dict[str, Any] = {"id": uuid.uuid4().hex, "reason": reason, "caller": None if caller is None else _text(caller, "caller"),
263
+ "job": None if job_id is None else str(job_id), "from": at, "until": until,
264
+ given[0]: size, "decision": dict(decision), "at": at}
265
+ if given[0] != "limit":
266
+ record["left"] = size
267
+ with self._lock:
268
+ data = self._load()
269
+ self._expire(data, utc_time(at))
270
+ for hold in data["holds"]:
271
+ if hold.get("closed") is None and hold["reason"] == reason and record["caller"] in (None, hold["caller"]) and (
272
+ job_id is None or str(job_id) in hold["jobs"]):
273
+ hold["closed"], hold["closedAt"], hold["lift"] = "lifted", at, record["id"]
274
+ data["lifts"].append(record)
275
+ self._save(data)
276
+ scope = ("for %s" % record["caller"]) if record["caller"] else "for every caller"
277
+ amount = {"jobs": "%s more job(s)", "usd": "USD %s more", "limit": "the limit at %s"}[given[0]] % size
278
+ self._emit(LIFT_EVENT_KIND, "info", "The operator lifted %s %s: %s%s." % (
279
+ REASON_WORDS[reason], scope, amount, (" until %s" % until) if until else ""),
280
+ {"type": "limit", "id": "%s:%s" % (reason, record["caller"] or "*"), "label": REASON_WORDS[reason]}, False)
281
+ return dict(record)
282
+
283
+ def _matching(self, data: Dict[str, Any], reason: str, caller: Optional[str], job_id: Optional[str], at: int,
284
+ kind: str) -> List[Dict[str, Any]]:
285
+ 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)]
287
+
288
+ def admit(self, *, reason: str, caller: str, job_id: Optional[str] = None, cost_usd: Optional[float] = None,
289
+ now: Optional[str] = None) -> Dict[str, Any]:
290
+ """Whether a lift lets work past the limit that would hold it: {admitted: True, lift} spends one job of a jobs
291
+ lift, or `cost_usd` of a money lift (the narrowest scope first: a job's, a caller's, every caller's), else
292
+ {admitted: False}. A raised limit is read with `limit`, not spent here."""
293
+ at = utc_time(self._now(now))
294
+ with self._lock:
295
+ data = self._load()
296
+ kind = "usd" if cost_usd is not None else "jobs"
297
+ for lift in sorted(self._matching(data, reason, caller, None if job_id is None else str(job_id), at, kind),
298
+ key=lambda l: (l.get("job") is None, l.get("caller") is None)):
299
+ need = cost_usd if kind == "usd" else 1
300
+ if lift["left"] >= need:
301
+ lift["left"] = round(lift["left"] - need, 6) if kind == "usd" else lift["left"] - 1
302
+ self._save(data)
303
+ return {"admitted": True, "lift": lift["id"], "left": lift["left"]}
304
+ return {"admitted": False}
305
+
306
+ def limit(self, *, reason: str, base: float, caller: Optional[str] = None, now: Optional[str] = None) -> float:
307
+ """The limit in force: `base`, or the highest live raised limit for (reason, caller); `base` again once the
308
+ window ends."""
309
+ at = utc_time(self._now(now))
310
+ raised = [l["limit"] for l in self._matching(self._load(), reason, caller, None, at, "limit")]
311
+ return max([base] + raised)
312
+
313
+ def lifts(self, now: Optional[str] = None) -> List[Dict[str, Any]]:
314
+ """Every lift with `live`: whether it still applies now."""
315
+ at = utc_time(self._now(now))
316
+ return [dict(l, live=self._live(l, at)) for l in self._load()["lifts"]]
@@ -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:
@@ -357,13 +361,43 @@ class _CheckedHTTPConnection(_po.CheckedHTTPConnection):
357
361
  self.vouch_pid = None # a loopback URL is never a process this caller started (FAA-267)
358
362
 
359
363
 
364
+ def _peer_is_loopback(sock: Any) -> bool:
365
+ """Whether a connected socket reached this computer's loopback, by the address it connected to, not by the URL's
366
+ spelling: a name the hosts file maps to 127.0.0.1, `0.0.0.0`, `::ffff:127.0.0.1` and a proxy on loopback all
367
+ arrive here (0.74.0 unit readings 7, 8 and 11; FAA-260)."""
368
+ try:
369
+ host = str(sock.getpeername()[0]).split("%", 1)[0]
370
+ address = ipaddress.ip_address(host)
371
+ except (OSError, ValueError, TypeError, IndexError, AttributeError):
372
+ return False
373
+ mapped = getattr(address, "ipv4_mapped", None)
374
+ return bool((mapped or address).is_loopback or address.is_unspecified)
375
+
376
+
377
+ class _PlainHTTPConnection(http.client.HTTPConnection):
378
+ """Plain http to a host not spelled as loopback (`insecure=True`) or to a proxy from the environment: when the
379
+ connected socket's peer is a loopback address, its server end is told as `_CheckedHTTPConnection` tells it, and
380
+ another account's, root's or an unknown owner is refused before a byte is sent (DEC-0154 HA-2, FAA-260)."""
381
+
382
+ def connect(self) -> None:
383
+ super().connect()
384
+ if not _peer_is_loopback(self.sock):
385
+ return
386
+ owner = _po.server_end(self.sock)
387
+ port = self.sock.getpeername()[1]
388
+ refused = _po.refusal(owner, port, None)
389
+ if refused is not None:
390
+ self.close()
391
+ raise _po.ListenerRefused(refused[0], "%s; nothing was sent" % refused[1], owner)
392
+
393
+
360
394
  def _opener(watchdog: _Watchdog, direct: bool = False) -> urllib.request.OpenerDirector:
361
395
  """`direct`: a loopback URL never goes through a proxy from the environment (`http_proxy`), so the connection the
362
396
  owner check reads is the one to the URL's own port (DEC-0154 HA-2), and a bearer never reaches a proxy."""
363
397
  class Http(urllib.request.HTTPHandler):
364
398
  def http_open(self, req):
365
399
  local = _is_loopback((urllib.parse.urlsplit(req.full_url).hostname or "").lower())
366
- return self.do_open(_watched(_CheckedHTTPConnection if local else http.client.HTTPConnection, watchdog), req)
400
+ return self.do_open(_watched(_CheckedHTTPConnection if local else _PlainHTTPConnection, watchdog), req)
367
401
 
368
402
  class Https(urllib.request.HTTPSHandler):
369
403
  def https_open(self, req):
@@ -385,7 +419,10 @@ def urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optio
385
419
  return _send(req, timeout_s, max_bytes, end, watchdog)
386
420
  except _po.ListenerRefused as exc:
387
421
  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
422
+ owner = getattr(exc, "owner", None)
423
+ why = str(getattr(owner, "reason", None) or getattr(owner, "kind", None) or getattr(exc, "code", "") or "unknown")
424
+ reason = _po.TABLE_RESTRICTED if why.startswith(_po.TABLE_RESTRICTED) else why.split(":", 1)[0]
425
+ raise HttpError("%s: foreign-user: %s" % (where, exc), url=redact_url(url), kind="foreign-user", reason=reason) from None
389
426
  except (OSError, http.client.HTTPException) as exc:
390
427
  if watchdog.fired:
391
428
  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)}