@passioncode-ai/fabric-agent-adapter 0.26.0 → 0.28.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 (22) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +6 -2
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +22 -13
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +174 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +2 -2
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +36 -1
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +60 -2
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1104 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +337 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +211 -2
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1274 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +397 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +294 -1
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +43 -2
  19. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  20. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  21. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +37 -7
  22. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
@@ -0,0 +1,397 @@
1
+ """The Fabric hub's door, as a service agent reaches it (fabric-hub/0.1; Fabric ADR-0115 §1, ADR-0149 §4; FAA-12b).
2
+
3
+ A service that answers a Project's requests on the board (`fabric-project-comms/0.1`) needs three things from Fabric's
4
+ hub, and this module does exactly those, standard library only:
5
+
6
+ 1. **Find the hub, every time.** `locate_hub()` re-reads `hub.json` beside Fabric's registry (macOS
7
+ `~/Library/Application Support/ai.passioncode.fabric`, Windows `%LOCALAPPDATA%\\passioncode-fabric`, Linux
8
+ `$XDG_DATA_HOME/passioncode-fabric`) the way Fabric's own `locateHub` does: a small regular file, never a link or a
9
+ FIFO; protocol `fabric-hub/0.1`; the origin exactly `http://127.0.0.1:<port>`; MCP at `/mcp`; a door token file
10
+ inside the registry root; and a `pid` that is alive. The port does not prove who holds it: while Fabric is down any
11
+ program can listen there, so a binding credential is sent only after this check passes, re-done before each send.
12
+ 2. **Ask once, keep the credential in a file.** `request_board_access()` asks the operator through the door for the
13
+ `board.respond` grant (callee `fabric`, resources `board:<project>/<capability>`); `poll_access()` writes the binding
14
+ credential, shown once, to a 0600 file and returns the status without it. Tokens live in files and headers only:
15
+ never in argv, a URL, a log line or an exception text.
16
+ 3. **Join the board.** `join_board()` calls `fabric.board.join {projectId}` with the binding credential and returns a
17
+ `BoardGrant`: a one-session bearer (spent by one MCP initialize; `fabric_board.BoardSession` opens it), the session
18
+ id and the capabilities the operator granted. Without a grant the answer is a `HubError` whose `data["ask"]` holds
19
+ the exact `fabric.access.request` arguments to ask with.
20
+
21
+ When Fabric is down every call raises `HubError` with `retryable=True`; `Backoff` gives the bounded, jittered wait a
22
+ loop sleeps before trying again (2 s doubling to 5 minutes), so nothing spins.
23
+
24
+ fabric-hub/0.1 publishes no hub key, so the door is trusted by the pid check alone. If a later `hub.json` names
25
+ `hubKey {kid, x}` and the caller passes an estate record (DEC-0055), `locate_hub(estate=…)` checks it with
26
+ `fabric_estate.hub_key_certified` and refuses with DEC-0060's own detail (`kid-not-certified`, `estate-outside-window`,
27
+ `estate-rolled-back`, `estate-forked`, `estate-unreadable`, `no-clock`)."""
28
+ from __future__ import annotations
29
+
30
+ import http.client
31
+ import json
32
+ import os
33
+ from pathlib import Path
34
+ import random
35
+ import re
36
+ import stat
37
+ import sys
38
+ import time
39
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
40
+
41
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
42
+ import fabric_service as _fs # noqa: E402 - beside this file: token files, atomic writes
43
+
44
+ HUB_PROTOCOL = "fabric-hub/0.1"
45
+ MCP_PROTOCOL_VERSION = "2025-06-18"
46
+ READ_CAP = 64 * 1024
47
+ LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{4,5})")
48
+ TOKEN = re.compile(r"[A-Za-z0-9_-]{43}")
49
+ UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
50
+ CAPABILITY = re.compile(r"[a-z][a-z0-9._-]{1,127}")
51
+
52
+
53
+ class HubError(Exception):
54
+ """A typed refusal or fault: `code` (`not-running`, `not-a-hub`, `hub-unavailable`, `credential`, `protocol`,
55
+ `hub-key-refused`, or the hub tool's own code such as `not_authorized`), a one-sentence `message`, `retryable`, and
56
+ `data` (never a secret). `str()` is the sentence."""
57
+
58
+ def __init__(self, code: str, message: str, retryable: bool = False, data: Optional[Dict[str, Any]] = None):
59
+ super().__init__(message)
60
+ self.code, self.message, self.retryable, self.data = code, message, retryable, data or {}
61
+
62
+ def __repr__(self) -> str:
63
+ return "HubError(%r, %r)" % (self.code, self.message)
64
+
65
+
66
+ # --- where Fabric is --------------------------------------------------------------------------------------------
67
+
68
+ def fabric_root(platform: str = sys.platform, env: Optional[Dict[str, str]] = None) -> Path:
69
+ """Fabric's registry root, where `hub.json` lives (Fabric `agentRegistry.ts#registryDirs`)."""
70
+ env = os.environ if env is None else env
71
+ home = Path(env.get("HOME") or env.get("USERPROFILE") or str(Path.home()))
72
+ if platform == "darwin":
73
+ return home / "Library/Application Support/ai.passioncode.fabric"
74
+ if platform == "win32":
75
+ return Path(env.get("LOCALAPPDATA") or str(home / "AppData" / "Local")) / "passioncode-fabric"
76
+ return Path(env.get("XDG_DATA_HOME") or str(home / ".local/share")) / "passioncode-fabric"
77
+
78
+
79
+ def pid_alive(pid: int) -> bool:
80
+ """Whether a process with this id runs as this user (Fabric `hub.ts#pidAlive`); never signals it."""
81
+ if pid <= 0:
82
+ return False
83
+ if sys.platform == "win32":
84
+ import ctypes
85
+ kernel = ctypes.windll.kernel32 # type: ignore[attr-defined]
86
+ handle = kernel.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
87
+ if not handle:
88
+ return False
89
+ try:
90
+ code = ctypes.c_ulong()
91
+ return bool(kernel.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
92
+ finally:
93
+ kernel.CloseHandle(handle)
94
+ try:
95
+ os.kill(pid, 0)
96
+ except PermissionError:
97
+ return False # another user's process under a reused id: not this user's Fabric
98
+ except OSError:
99
+ return False
100
+ return True
101
+
102
+
103
+ def _read_small(path: Path) -> Optional[str]:
104
+ """A small regular file's text without following a link or blocking on a FIFO; None when it is not one."""
105
+ info = os.lstat(str(path))
106
+ if not stat.S_ISREG(info.st_mode) or info.st_size > READ_CAP:
107
+ return None
108
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
109
+ fd = os.open(str(path), flags)
110
+ try:
111
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
112
+ return None
113
+ data = os.read(fd, READ_CAP + 1)
114
+ finally:
115
+ os.close(fd)
116
+ if len(data) > READ_CAP:
117
+ return None
118
+ try:
119
+ return data.decode("utf-8")
120
+ except UnicodeDecodeError:
121
+ return None
122
+
123
+
124
+ def locate_hub(root: Optional[Path] = None, alive: Callable[[int], bool] = pid_alive, estate: Optional[Dict[str, Any]] = None,
125
+ now: Optional[float] = None) -> Dict[str, Any]:
126
+ """`hub.json` read the way an agent must, every call. Answers `{origin, mcpUrl, doorTokenFile, pid, startedAt,
127
+ features}` or raises `HubError` (`not-running`, retryable; `not-a-hub`). Reads no token. `estate` is
128
+ `{"record": <estate record>, "applied": {"revision", "digest"?} | None}`: with it, a `hubKey` the document names is
129
+ checked by `hub_key_certified` (DEC-0060); fabric-hub/0.1 names none, and then nothing is checked."""
130
+ root = Path(root) if root is not None else fabric_root()
131
+ hub_file = root / "hub.json"
132
+ not_hub = lambda reason: HubError("not-a-hub", "Fabric's hub.json %s." % reason) # noqa: E731
133
+ try:
134
+ text = _read_small(hub_file)
135
+ except FileNotFoundError:
136
+ raise HubError("not-running", "Fabric is not running: there is no hub.json.", True) from None
137
+ except OSError as exc:
138
+ raise HubError("not-a-hub", "Fabric's hub.json could not be read (%s)." % (exc.strerror or "error"), True) from None
139
+ if text is None:
140
+ raise not_hub("is not a small regular file")
141
+ try:
142
+ doc = json.loads(text)
143
+ except ValueError:
144
+ raise not_hub("is not JSON") from None
145
+ if not isinstance(doc, dict) or doc.get("protocol") != HUB_PROTOCOL:
146
+ raise not_hub("does not speak %s" % HUB_PROTOCOL)
147
+ origin = doc.get("origin") if isinstance(doc.get("origin"), str) else ""
148
+ match = LOOPBACK_ORIGIN.fullmatch(origin)
149
+ if not match or not 1024 <= int(match.group(1)) <= 65535:
150
+ raise not_hub("names an origin that is not the loopback literal")
151
+ if doc.get("mcp") != "/mcp":
152
+ raise not_hub("names another MCP path")
153
+ pid = doc.get("pid")
154
+ if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
155
+ raise not_hub("names no process")
156
+ token_file = doc.get("doorTokenFile") if isinstance(doc.get("doorTokenFile"), str) else ""
157
+ if not os.path.isabs(token_file) or os.path.realpath(os.path.dirname(token_file)) != os.path.realpath(str(root)):
158
+ raise not_hub("names a door token outside the registry root")
159
+ if not alive(pid):
160
+ raise HubError("not-running", "Fabric is not running: hub.json names process %d, which is not running." % pid, True)
161
+ features = doc.get("features")
162
+ features = [f for f in features if isinstance(f, str)] if isinstance(features, list) else []
163
+ if estate is not None and isinstance(doc.get("hubKey"), dict):
164
+ import fabric_estate # beside this file
165
+ key = doc["hubKey"]
166
+ verdict = fabric_estate.hub_key_certified(estate.get("record"), key.get("kid"), key.get("x"),
167
+ time.time() if now is None else now, estate.get("applied"))
168
+ if not verdict.get("certified"):
169
+ raise HubError("hub-key-refused", "The estate record does not certify the hub's key (%s)." % verdict.get("detail"),
170
+ data={"fault": verdict.get("fault"), "detail": verdict.get("detail")})
171
+ return {"origin": origin, "mcpUrl": origin + "/mcp", "doorTokenFile": token_file, "pid": pid,
172
+ "startedAt": doc.get("startedAt"), "features": features}
173
+
174
+
175
+ def read_door_token(location: Dict[str, Any]) -> str:
176
+ """The door token hub.json names: a 0600 file of this user, never a link (`fabric_service.read_token`), 43
177
+ base64url characters. Raises `HubError` naming the file, never the contents."""
178
+ path = Path(location["doorTokenFile"])
179
+ try:
180
+ token = _fs.read_token(path)
181
+ except FileNotFoundError:
182
+ raise HubError("not-running", "Fabric is starting or stopping: its door token is not there.", True) from None
183
+ except (OSError, _fs.ServiceError) as exc:
184
+ raise HubError("not-a-hub", "The door token file is not one Fabric writes (%s)." % exc) from None
185
+ if not TOKEN.fullmatch(token):
186
+ raise HubError("not-a-hub", "The door token is not one Fabric writes.")
187
+ return token
188
+
189
+
190
+ def read_credential(path: Path) -> str:
191
+ """A binding credential from its 0600 file (`fabric_service.read_token`): 43 base64url characters."""
192
+ try:
193
+ token = _fs.read_token(Path(path))
194
+ except FileNotFoundError:
195
+ raise HubError("credential", "No binding credential at %s: ask for the grant first (request_board_access)." % path) from None
196
+ except (OSError, _fs.ServiceError) as exc:
197
+ raise HubError("credential", str(exc)) from None
198
+ if not TOKEN.fullmatch(token):
199
+ raise HubError("credential", "The file %s does not hold a Fabric binding credential." % path)
200
+ return token
201
+
202
+
203
+ # --- MCP over HTTP, loopback only ---------------------------------------------------------------------------------
204
+
205
+ def post_mcp(origin: str, body: Dict[str, Any], bearer: str, session: Optional[str] = None, timeout: float = 15.0,
206
+ method: str = "POST", protocol_version: Optional[str] = None) -> Tuple[int, Dict[str, str], Any]:
207
+ """One MCP request to `<origin>/mcp` with a bearer in the header. Only `http://127.0.0.1:<port>` is accepted; no
208
+ proxy is consulted. Answers (status, lower-cased headers, parsed JSON or None); an SSE answer is read to its first
209
+ `data:` line. A refused connection is `HubError("hub-unavailable", retryable=True)`."""
210
+ match = LOOPBACK_ORIGIN.fullmatch(origin)
211
+ if not match:
212
+ raise HubError("not-a-hub", "Only the hub's loopback origin is used.")
213
+ headers = {"Authorization": "Bearer " + bearer, "Content-Type": "application/json",
214
+ "Accept": "application/json, text/event-stream", "Host": "127.0.0.1:%s" % match.group(1)}
215
+ if session:
216
+ headers["mcp-session-id"] = session
217
+ if protocol_version:
218
+ headers["mcp-protocol-version"] = protocol_version
219
+ conn = http.client.HTTPConnection("127.0.0.1", int(match.group(1)), timeout=timeout)
220
+ try:
221
+ conn.request(method, "/mcp", body=None if method == "DELETE" else json.dumps(body).encode(), headers=headers)
222
+ resp = conn.getresponse()
223
+ raw = resp.read(4 * 1024 * 1024)
224
+ got = {k.lower(): v for k, v in resp.getheaders()}
225
+ except (OSError, http.client.HTTPException) as exc:
226
+ raise HubError("hub-unavailable", "Fabric's hub did not answer (%s)." % exc.__class__.__name__, True) from None
227
+ finally:
228
+ conn.close()
229
+ text = raw.decode("utf-8", "replace")
230
+ if "text/event-stream" in got.get("content-type", ""):
231
+ text = next((line[5:].strip() for line in text.splitlines() if line.startswith("data:")), "")
232
+ try:
233
+ parsed = json.loads(text) if text.strip() else None
234
+ except ValueError:
235
+ parsed = None
236
+ return resp.status, got, parsed
237
+
238
+
239
+ def tool_answer(status: int, parsed: Any, tool: str) -> Dict[str, Any]:
240
+ """A `tools/call` answer as a dict: `structuredContent`, else the first text content parsed as JSON. HTTP 401 is
241
+ `credential`, 429/5xx `hub-unavailable` (retryable), a JSON-RPC error or an unreadable answer `protocol`."""
242
+ if status == 401:
243
+ raise HubError("credential", "The hub refused the credential for %s." % tool)
244
+ if status == 429 or status >= 500:
245
+ raise HubError("hub-unavailable", "Fabric's hub could not take %s just now (HTTP %d)." % (tool, status), True)
246
+ if status != 200 or not isinstance(parsed, dict):
247
+ raise HubError("protocol", "Fabric's hub answered %s with HTTP %d and no MCP answer." % (tool, status), True)
248
+ if "error" in parsed:
249
+ message = parsed["error"].get("message") if isinstance(parsed["error"], dict) else None
250
+ raise HubError("protocol", "Fabric refused %s: %s" % (tool, str(message or "an MCP error")[:200]))
251
+ result = parsed.get("result")
252
+ if not isinstance(result, dict):
253
+ raise HubError("protocol", "Fabric's answer to %s has no result." % tool)
254
+ value = result.get("structuredContent")
255
+ if not isinstance(value, dict):
256
+ content = result.get("content")
257
+ text = content[0].get("text") if isinstance(content, list) and content and isinstance(content[0], dict) else None
258
+ try:
259
+ value = json.loads(text) if isinstance(text, str) else None
260
+ except ValueError:
261
+ value = None
262
+ if not isinstance(value, dict):
263
+ raise HubError("protocol", "Fabric's answer to %s is not a JSON object." % tool)
264
+ return value
265
+
266
+
267
+ # --- the client ----------------------------------------------------------------------------------------------------
268
+
269
+ class BoardGrant:
270
+ """What `fabric.board.join` handed out. The bearer is kept off `repr` and every log; `BoardSession` spends it."""
271
+
272
+ __slots__ = ("_bearer", "session_id", "project_id", "capabilities", "origin", "pid")
273
+
274
+ def __init__(self, bearer: str, session_id: str, project_id: str, capabilities: Sequence[str], origin: str, pid: int):
275
+ self._bearer = bearer
276
+ self.session_id, self.project_id, self.capabilities = session_id, project_id, list(capabilities)
277
+ self.origin, self.pid = origin, pid
278
+
279
+ def bearer(self) -> str:
280
+ return self._bearer
281
+
282
+ def __repr__(self) -> str:
283
+ return "BoardGrant(session_id=%r, project_id=%r, capabilities=%r)" % (self.session_id, self.project_id, self.capabilities)
284
+
285
+
286
+ def board_resource(project_id: str, capability: str) -> str:
287
+ """The grant's resource, `board:<project>/<capability>` (ADR-0149 §2)."""
288
+ return "board:%s/%s" % (project_id, capability)
289
+
290
+
291
+ def _project(project_id: str) -> str:
292
+ """A Project id as Fabric's board compares it: a uuid lower-cased (Fabric `boardService.ts`, migration 81); the
293
+ contract's ids are opaque and compared as written (DEC-0077), so nothing else is touched."""
294
+ if not isinstance(project_id, str) or not UUID.fullmatch(project_id):
295
+ raise HubError("protocol", "fabric.board.join takes a Project id that is a uuid.")
296
+ return project_id.lower()
297
+
298
+
299
+ class HubClient:
300
+ """The door client. `root` defaults to Fabric's registry root for this platform; `alive` and `post` are seams for
301
+ tests. Every call re-reads `hub.json` first."""
302
+
303
+ def __init__(self, root: Optional[Path] = None, *, alive: Callable[[int], bool] = pid_alive, timeout: float = 15.0,
304
+ estate: Optional[Dict[str, Any]] = None, post: Callable[..., Tuple[int, Dict[str, str], Any]] = post_mcp):
305
+ self.root = Path(root) if root is not None else fabric_root()
306
+ self.alive, self.timeout, self.estate, self.post = alive, timeout, estate, post
307
+ self._rpc = 0
308
+
309
+ def locate(self) -> Dict[str, Any]:
310
+ return locate_hub(self.root, self.alive, self.estate)
311
+
312
+ def _call(self, bearer_of: Callable[[Dict[str, Any]], str], tool: str, args: Dict[str, Any]) -> Dict[str, Any]:
313
+ location = self.locate() # the pid check, right before the credential leaves this process
314
+ bearer = bearer_of(location)
315
+ self._rpc += 1
316
+ status, _, parsed = self.post(location["origin"], {"jsonrpc": "2.0", "id": self._rpc, "method": "tools/call",
317
+ "params": {"name": tool, "arguments": args}}, bearer, timeout=self.timeout)
318
+ value = tool_answer(status, parsed, tool)
319
+ error = value.get("error")
320
+ if isinstance(error, dict) and isinstance(error.get("code"), str):
321
+ code = error["code"]
322
+ raise HubError(code, str(error.get("message") or "refused")[:300], code == "hub-unavailable",
323
+ error.get("data") if isinstance(error.get("data"), dict) else None)
324
+ return value
325
+
326
+ def call_door(self, tool: str, args: Dict[str, Any]) -> Dict[str, Any]:
327
+ return self._call(read_door_token, tool, args)
328
+
329
+ def call_binding(self, tool: str, args: Dict[str, Any], credential_file: Path) -> Dict[str, Any]:
330
+ return self._call(lambda _location: read_credential(credential_file), tool, args)
331
+
332
+ # -- the grant -----------------------------------------------------------------------------------------------
333
+ def request_board_access(self, agent_id: str, project_id: str, capabilities: Sequence[str], reason: str) -> Dict[str, Any]:
334
+ """Ask the operator, once, to let `agent_id` answer `capabilities` for `project_id` (`fabric.access.request`,
335
+ callee `fabric`, capability `board.respond`). Answers `{requestId, status, expiresAt, pollSecret?, note}`; keep the
336
+ pollSecret in memory or a 0600 file — it is the only way to read the answer through the door."""
337
+ project = _project(project_id)
338
+ caps = list(capabilities)
339
+ if not caps or any(not isinstance(c, str) or not CAPABILITY.fullmatch(c) for c in caps):
340
+ raise HubError("protocol", "Name at least one capability, each [a-z][a-z0-9._-]{1,127}.")
341
+ return self.call_door("fabric.access.request", {"agentId": agent_id, "callee": "fabric", "capabilities": ["board.respond"],
342
+ "resources": [board_resource(project, c) for c in caps], "reason": reason})
343
+
344
+ def poll_access(self, request_id: str, poll_secret: str, credential_file: Path) -> Dict[str, Any]:
345
+ """Read the request through the door. On Allow the first read carries the credential, once: it is written to
346
+ `credential_file` (0600, atomic, fsync) and removed from the answer, which says `credentialSaved: true`."""
347
+ answer = self.call_door("fabric.access.status", {"requestId": request_id, "pollSecret": poll_secret})
348
+ credential = answer.pop("credential", None)
349
+ if isinstance(credential, str):
350
+ if not TOKEN.fullmatch(credential):
351
+ raise HubError("protocol", "Fabric handed out a credential this kit does not recognise; nothing was saved.")
352
+ _fs.ensure_private_dir(Path(credential_file).parent)
353
+ _fs.atomic_write(Path(credential_file), credential.encode(), 0o600)
354
+ answer["credentialSaved"] = True
355
+ return answer
356
+
357
+ def join_board(self, project_id: str, credential_file: Path) -> BoardGrant:
358
+ """`fabric.board.join {projectId}`: a board session for one Project. Refusal `not_authorized` carries
359
+ `data["ask"]`, the `fabric.access.request` arguments to ask with (capabilities to be filled by the caller)."""
360
+ project = _project(project_id)
361
+ try:
362
+ answer = self.call_binding("fabric.board.join", {"projectId": project}, credential_file)
363
+ except HubError as exc:
364
+ if exc.code in ("not_authorized", "not-authorized"):
365
+ raise HubError("not_authorized", "The operator has not let this agent answer requests for that Project: ask "
366
+ "with fabric.access.request (callee fabric, capability board.respond, resource "
367
+ "board:<project>/<capability>).", data={"ask": {"callee": "fabric", "capabilities": ["board.respond"],
368
+ "resources": [board_resource(project, "<capability>")]}}) from None
369
+ raise
370
+ session = answer.get("session")
371
+ if not isinstance(session, dict) or not isinstance(session.get("bearer"), str) or not isinstance(session.get("sessionId"), str):
372
+ raise HubError("protocol", "fabric.board.join answered no board session.")
373
+ if session.get("projectId") != project:
374
+ raise HubError("protocol", "fabric.board.join answered a session for another Project.")
375
+ caps = session.get("capabilities")
376
+ caps = [c for c in caps if isinstance(c, str)] if isinstance(caps, list) else []
377
+ location = self.locate()
378
+ return BoardGrant(session["bearer"], session["sessionId"], project, caps, location["origin"], location["pid"])
379
+
380
+
381
+ class Backoff:
382
+ """Bounded exponential backoff with jitter: `first` seconds doubling to `cap`, each wait ±`jitter` of itself and
383
+ never under `floor`. `next()` is the wait to sleep; `reset()` after a success."""
384
+
385
+ def __init__(self, first: float = 2.0, cap: float = 300.0, jitter: float = 0.2, floor: float = 1.0,
386
+ rand: Callable[[], float] = random.random):
387
+ self.first, self.cap, self.jitter, self.floor, self.rand = first, cap, jitter, floor, rand
388
+ self.attempt = 0
389
+
390
+ def next(self) -> float:
391
+ base = min(self.cap, self.first * (2 ** min(self.attempt, 32)))
392
+ self.attempt += 1
393
+ wait = base * (1 + self.jitter * (2 * self.rand() - 1))
394
+ return max(self.floor, min(self.cap, wait))
395
+
396
+ def reset(self) -> None:
397
+ self.attempt = 0