@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
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env python3
2
+ """The entry of a dashboard-only fabric-service/0.1 service (FAA-268), the descriptor install.py wrote.
3
+
4
+ python3 dashboard_service.py serve --descriptor <path>
5
+
6
+ It serves the well-known document (surfaces `dashboard` and `events`, no `mcp`, no `usage`: it calls no model and
7
+ offers no capability), its events feed, the login code and the signed-in page. The page shows `<folder>/snapshot.json`,
8
+ which the person's agent writes from what it read through the Fabric Dashboards MCP (`list_services`, `spend`,
9
+ `connections`) and Project Observatory; the service holds no token but its own and calls nobody. A missing snapshot,
10
+ one that cannot be read and one older than a day are reported in `degraded`, never shown as data.
11
+
12
+ snapshot.json: {"takenAt": "<RFC 3339>", "sections": [{"title": str, "source": str, "columns": [str], "rows": [[str]]}]}
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import datetime as _dt
19
+ import html
20
+ import json
21
+ import os
22
+ import sys
23
+ import time
24
+ from http.server import BaseHTTPRequestHandler
25
+ from pathlib import Path
26
+ from typing import Any, Dict, List, Optional
27
+ from urllib.parse import parse_qs, urlparse
28
+
29
+ if str(Path(__file__).resolve().parent) not in sys.path: # the kit installed beside it, under `python3 -I` too
30
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
31
+ try:
32
+ import fabric_service as fs
33
+ except ImportError: # in the adapter's repository: the kit is two folders up
34
+ sys.path.append(str(Path(__file__).resolve().parents[2] / "scripts"))
35
+ import fabric_service as fs
36
+
37
+ VERSION = "0.1.0"
38
+ STALE_SECONDS = 24 * 3600
39
+ MAX_SNAPSHOT_BYTES = 1048576
40
+ _HERE = Path(__file__).resolve().parent
41
+ KIT_DIR = next((d for d in (_HERE / "kit", _HERE.parents[1] / "assets" / "dashboard-kit")
42
+ if (d / "fabric-dashboard.css").is_file()), None)
43
+ KIT_FILES = {"/kit/fabric-dashboard.css": ("fabric-dashboard.css", "text/css; charset=utf-8"),
44
+ "/kit/fabric-dashboard.js": ("fabric-dashboard.js", "text/javascript; charset=utf-8")}
45
+ CONNECTIONS = [{"id": "snapshot", "kind": "store", "target": "snapshot.json",
46
+ "purpose": "what the person's agent read through the Fabric Dashboards MCP and Project Observatory"}]
47
+ PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
48
+ <meta name="viewport" content="width=device-width,initial-scale=1"><title>{name}</title>
49
+ <link rel="stylesheet" href="/kit/fabric-dashboard.css"><script src="/kit/fabric-dashboard.js" defer></script>
50
+ </head><body><div class="fd-chrome"><header class="fd-header"><p class="fd-title">{name}</p>
51
+ <span class="fd-chip fd-s-{chip}"><span class="fd-dot"></span>Status: {status}</span></header></div>
52
+ <main class="fd-page">{body}</main></body></html>"""
53
+
54
+
55
+ def read_snapshot(path: Path, now: Optional[float] = None) -> Dict[str, Any]:
56
+ """{"sections": [...], "takenAt": str} or {"degraded": reason}: missing, unreadable, invalid, stale."""
57
+ try:
58
+ if path.stat().st_size > MAX_SNAPSHOT_BYTES:
59
+ return {"degraded": "too-large"}
60
+ doc = json.loads(path.read_text(encoding="utf-8"))
61
+ except FileNotFoundError:
62
+ return {"degraded": "missing"}
63
+ except (OSError, ValueError, RecursionError):
64
+ return {"degraded": "unreadable"}
65
+ sections = doc.get("sections") if isinstance(doc, dict) else None
66
+ taken = doc.get("takenAt") if isinstance(doc, dict) else None
67
+ try:
68
+ at = _dt.datetime.fromisoformat(str(taken).replace("Z", "+00:00")).timestamp()
69
+ except ValueError:
70
+ return {"degraded": "invalid"}
71
+ if not isinstance(sections, list) or not all(isinstance(s, dict) for s in sections):
72
+ return {"degraded": "invalid"}
73
+ if (now if now is not None else time.time()) - at > STALE_SECONDS:
74
+ return {"degraded": "stale", "takenAt": taken}
75
+ return {"sections": sections, "takenAt": taken}
76
+
77
+
78
+ def _cell(value: Any) -> str:
79
+ return html.escape(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False))
80
+
81
+
82
+ def render_sections(snapshot: Dict[str, Any]) -> str:
83
+ if "degraded" in snapshot:
84
+ return ('<section><h2>No data</h2><p class="fd-hint">The snapshot is %s. Ask your agent to read the Fabric '
85
+ 'Dashboards MCP and Project Observatory and write snapshot.json.</p></section>' % html.escape(snapshot["degraded"]))
86
+ out = ['<p class="fd-hint">Read at %s.</p>' % _cell(snapshot["takenAt"])]
87
+ for section in snapshot["sections"]:
88
+ columns = section.get("columns") if isinstance(section.get("columns"), list) else []
89
+ rows = section.get("rows") if isinstance(section.get("rows"), list) else []
90
+ head = "".join("<th>%s</th>" % _cell(c) for c in columns)
91
+ body = "".join("<tr>%s</tr>" % "".join("<td>%s</td>" % _cell(c) for c in (r if isinstance(r, list) else [r]))
92
+ for r in rows[:500])
93
+ out.append('<section><h2>%s</h2><p class="fd-hint">From %s.</p><table><thead><tr>%s</tr></thead><tbody>%s</tbody>'
94
+ "</table></section>" % (_cell(section.get("title", "")), _cell(section.get("source", "")), head, body))
95
+ return "".join(out)
96
+
97
+
98
+ class Service:
99
+ def __init__(self, descriptor_file: Path):
100
+ d = json.loads(Path(descriptor_file).read_text(encoding="utf-8"))
101
+ problems = fs.validate_descriptor(d)
102
+ if problems:
103
+ raise fs.ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
104
+ self.id, self.instance, self.name = d["id"], d["instance"], d["name"]
105
+ self.port = fs.port_of(d["origin"])
106
+ self.data = Path(d["paths"]["data"])
107
+ self.token_file = Path(d["auth"]["tokenFile"]).expanduser()
108
+ self.started_at = fs.now_iso()
109
+ self.build = {"commit": os.environ.get("FABRIC_BUILD_COMMIT", "0000000"), "builtAt": self.started_at}
110
+ self.drain = fs.Drain()
111
+
112
+ def start(self) -> None:
113
+ self.lock = fs.hold_single_instance(self.data) # the lock before any side effect
114
+ self.token = fs.ensure_token(self.token_file)
115
+ self.log = fs.JsonlEventLog(self.data / "events.jsonl")
116
+ self.codes = fs.LoginCodes(self.data / "auth", token=self.token)
117
+ self.log.append("service.started", "info", "%s started on build %s." % (self.name, self.build["commit"]))
118
+
119
+ def snapshot(self) -> Dict[str, Any]:
120
+ return read_snapshot(self.data / "snapshot.json")
121
+
122
+ def well_known(self) -> Dict[str, Any]:
123
+ snap = self.snapshot()
124
+ degraded = [{"source": "snapshot", "reason": snap["degraded"]}] if "degraded" in snap else []
125
+ return fs.build_well_known(
126
+ service_id=self.id, instance=self.instance, name=self.name, version=VERSION, build=self.build,
127
+ started_at=self.started_at, status="ready", degraded=degraded,
128
+ summary=[{"label": "Sections", "value": len(snap.get("sections") or [])}],
129
+ surfaces={"dashboard": {"path": "/", "login": True}, "events": {"path": "/fabric/v1/events"}},
130
+ extensions=dict(fs.dashboard_extension() if KIT_DIR is not None else {},
131
+ **fs.connections_extension(CONNECTIONS, degraded=degraded)))
132
+
133
+
134
+ def make_handler(svc: Service):
135
+ class Handler(BaseHTTPRequestHandler):
136
+ server_version = "fabric-dashboard-only/" + VERSION
137
+
138
+ def log_message(self, fmt: str, *args: Any) -> None:
139
+ return
140
+
141
+ def _send(self, status: int, body: Any = None, headers: Optional[Dict[str, str]] = None,
142
+ content_type: str = "application/json") -> None:
143
+ payload = b"" if body is None else (body.encode() if isinstance(body, str) else json.dumps(body).encode())
144
+ self.send_response(status)
145
+ self.send_header("Cache-Control", "no-store")
146
+ self.send_header("X-Content-Type-Options", "nosniff")
147
+ self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'self'; script-src 'self'; "
148
+ "connect-src 'self'; frame-ancestors 'self'")
149
+ if body is not None:
150
+ self.send_header("Content-Type", content_type)
151
+ self.send_header("Content-Length", str(len(payload)))
152
+ for key, value in (headers or {}).items():
153
+ self.send_header(key, value)
154
+ self.end_headers()
155
+ if payload:
156
+ self.wfile.write(payload)
157
+
158
+ def _guard(self) -> bool:
159
+ reason = fs.check_request(svc.port, self.headers.get("Host"), self.headers.get("Origin"),
160
+ self.headers.get("Sec-Fetch-Site"))
161
+ if reason:
162
+ self._send(403, {"error": reason})
163
+ return False
164
+ return True
165
+
166
+ def _token_ok(self) -> bool:
167
+ if fs.token_matches(self.headers.get("Authorization"), svc.token):
168
+ return True
169
+ self._send(401, {"error": "The service token is required."}, {"WWW-Authenticate": "Bearer"})
170
+ return False
171
+
172
+ def do_GET(self) -> None: # noqa: N802
173
+ if not self._guard():
174
+ return
175
+ url = urlparse(self.path)
176
+ query = parse_qs(url.query)
177
+ if url.path == "/.well-known/fabric-service":
178
+ return self._send(200, svc.well_known())
179
+ if url.path == "/fabric/v1/events":
180
+ if not self._token_ok():
181
+ return
182
+ try:
183
+ limit = fs.parse_limit((query.get("limit") or [None])[0])
184
+ return self._send(200, fs.events_page(svc.log.fetch, (query.get("after") or [None])[0], limit))
185
+ except fs.ServiceError as exc:
186
+ return self._send(400, {"error": str(exc)})
187
+ if url.path == "/fabric/v1/login":
188
+ cookie = svc.codes.redeem((query.get("code") or [None])[0])
189
+ if not cookie:
190
+ return self._send(403, "This sign-in link was already used or has expired.", content_type="text/plain")
191
+ return self._send(302, None, {"Location": "/", "Set-Cookie": fs.session_cookie_header(cookie)})
192
+ if url.path in KIT_FILES and KIT_DIR is not None:
193
+ name, kind = KIT_FILES[url.path]
194
+ return self._send(200, (KIT_DIR / name).read_text(encoding="utf-8"), content_type=kind)
195
+ if url.path == "/":
196
+ if not svc.codes.session_valid(fs.cookie_value(self.headers.get("Cookie"))):
197
+ return self._send(401, "Open this dashboard from Fabric Dashboards.", content_type="text/plain")
198
+ snap = svc.snapshot()
199
+ status, chip = ("degraded", "warn") if "degraded" in snap else ("ready", "ok")
200
+ return self._send(200, PAGE.format(name=html.escape(svc.name), status=status, chip=chip,
201
+ body=render_sections(snap)), content_type="text/html; charset=utf-8")
202
+ self._send(404, {"error": "Not found."})
203
+
204
+ def do_POST(self) -> None: # noqa: N802
205
+ if not self._guard():
206
+ return
207
+ url = urlparse(self.path)
208
+ if url.path == "/fabric/v1/login-code":
209
+ if not self._token_ok():
210
+ return
211
+ return self._send(200, svc.codes.issue())
212
+ if url.path == "/logout":
213
+ if self.headers.get("X-Fabric-Request") != "1": # another site's form never signs the operator out
214
+ return self._send(403, {"error": "Missing request header."})
215
+ svc.codes.logout(fs.cookie_value(self.headers.get("Cookie")))
216
+ return self._send(204, None, {"Set-Cookie": fs.expired_session_cookie_header()})
217
+ self._send(404, {"error": "Not found."})
218
+
219
+ return Handler
220
+
221
+
222
+ def serve(descriptor_file: Path) -> int:
223
+ svc = Service(descriptor_file)
224
+ svc.start()
225
+ server = fs.LoopbackHTTPServer(("127.0.0.1", svc.port), make_handler(svc))
226
+ svc.drain.install(lambda drained: server.shutdown(),
227
+ on_stopping=lambda: svc.log.append("service.stopping", "info", "%s is stopping." % svc.name))
228
+ try:
229
+ server.serve_forever(poll_interval=0.2)
230
+ finally:
231
+ server.server_close()
232
+ svc.lock.release()
233
+ return 0
234
+
235
+
236
+ def main(argv: Optional[List[str]] = None) -> int:
237
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
238
+ fs.add_supervisor_argument(parser)
239
+ sub = parser.add_subparsers(dest="command", required=True)
240
+ p = sub.add_parser("serve")
241
+ fs.add_supervisor_argument(p)
242
+ p.add_argument("--descriptor", required=True, help="the path install.py printed")
243
+ args = parser.parse_args(argv)
244
+ try:
245
+ return serve(Path(args.descriptor))
246
+ except (fs.ServiceError, OSError, ValueError) as exc:
247
+ print(str(exc), file=sys.stderr)
248
+ return 1
249
+
250
+
251
+ if __name__ == "__main__":
252
+ raise SystemExit(main())
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ // Install a dashboard-only fabric-service/0.1 service (FAA-268): the twin of install.py, the same flags, the same
3
+ // descriptor bytes (but installedAt) and the same last stdout line. Read install.py's docstring for the rules.
4
+ // node install.mjs --id <id> --folder <folder> [--instance default] [--name NAME] [--repository URI]
5
+ // [--port N] [--label LABEL --plist PLIST] [--services-dir DIR] [--json]
6
+ // Exit codes: 0 installed or already installed, 1 refused (the reason on stderr), 2 usage.
7
+ import { execFileSync } from 'node:child_process';
8
+ import fs from 'node:fs';
9
+ import net from 'node:net';
10
+ import path from 'node:path';
11
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
+ import { parseArgs } from 'node:util';
13
+
14
+ const here = path.dirname(fileURLToPath(import.meta.url));
15
+ const beside = path.join(here, 'fabric-service.mjs'); // the kit installed beside it, else the adapter's repository
16
+ const k = await import(pathToFileURL(fs.existsSync(beside) ? beside : path.join(here, '..', '..', 'scripts', 'fabric-service.mjs')).href);
17
+
18
+ export const VERSION = '0.1.0';
19
+ export const TOOL = `dashboard-only install ${VERSION}`;
20
+ export const PORT_FIRST = 47300;
21
+ export const PORT_LAST = 47999;
22
+ const ATTEMPTS = 8;
23
+
24
+ function claimedPorts(services, me) {
25
+ const ports = new Set();
26
+ for (const [, other] of k.readDescriptors(services)) {
27
+ if (`${other.id}.${other.instance ?? 'default'}` === me || other.placement === 'remote') continue;
28
+ try { ports.add(k.portOf(String(other.origin ?? ''))); } catch { /* an app names no port */ }
29
+ }
30
+ return ports;
31
+ }
32
+
33
+ function freeNow(port) {
34
+ return new Promise((resolve) => {
35
+ const server = net.createServer();
36
+ server.once('error', () => resolve(false));
37
+ server.listen({ host: '127.0.0.1', port, exclusive: true }, () => server.close(() => resolve(true)));
38
+ });
39
+ }
40
+
41
+ async function candidates(services, me, wanted) {
42
+ if (wanted !== undefined) return [wanted];
43
+ const taken = claimedPorts(services, me);
44
+ const out = [];
45
+ for (let p = PORT_FIRST; p <= PORT_LAST && out.length < ATTEMPTS; p += 1) {
46
+ if (!taken.has(p) && await freeNow(p)) out.push(p);
47
+ }
48
+ return out;
49
+ }
50
+
51
+ function gitOrigin(folder) {
52
+ try {
53
+ const url = execFileSync('git', ['-C', folder, 'config', '--get', 'remote.origin.url'],
54
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).trim();
55
+ if (!url.startsWith('https://')) return null;
56
+ return url.endsWith('.git') ? url.slice(0, -4) : url;
57
+ } catch { return null; }
58
+ }
59
+
60
+ // As Python's Path.resolve(): the longest existing ancestor with its links resolved, then the rest as written.
61
+ function resolved(p) {
62
+ let head = path.resolve(p);
63
+ const rest = [];
64
+ while (!fs.existsSync(head) && path.dirname(head) !== head) { rest.unshift(path.basename(head)); head = path.dirname(head); }
65
+ return path.join(fs.realpathSync(head), ...rest);
66
+ }
67
+
68
+ // The line install.py prints: json.dumps(result, ensure_ascii=False), key order descriptor, key, port.
69
+ export const line = (r) => `{"descriptor": ${JSON.stringify(r.descriptor)}, "key": ${JSON.stringify(r.key)}, "port": ${r.port}}`;
70
+
71
+ function answer(file, d) {
72
+ return { descriptor: file, key: `${d.id}.${d.instance}`, port: k.portOf(d.origin) };
73
+ }
74
+
75
+ function build(a, folder, port, repository) {
76
+ const d = {
77
+ protocol: k.PROTOCOL, id: a.id, instance: a.instance, name: a.name || a.id,
78
+ summary: 'A dashboard-only service: no capabilities, no provider keys.',
79
+ origin: `http://127.0.0.1:${port}`,
80
+ auth: { tokenFile: path.join(folder, 'service.token') },
81
+ lifecycle: a.label ? { manager: 'launchd', label: a.label, plist: a.plist } : { manager: 'none' },
82
+ paths: { data: folder, logs: [] },
83
+ installedAt: k.nowIso(),
84
+ installedBy: { by: 'person', role: 'operator', tool: TOOL },
85
+ };
86
+ if (repository) d.source = { repository };
87
+ return d;
88
+ }
89
+
90
+ export async function install(a) {
91
+ const services = a.servicesDir ? k.expand(a.servicesDir) : k.servicesDir();
92
+ const folder = resolved(k.expand(a.folder));
93
+ const me = `${a.id}.${a.instance}`;
94
+ const installed = k.descriptorPath(a.id, a.instance, services);
95
+ if (fs.existsSync(installed) && fs.statSync(installed).isFile()) {
96
+ let current;
97
+ try { current = JSON.parse(fs.readFileSync(installed, 'utf8')); } catch (err) {
98
+ throw new k.ServiceError(`The installed descriptor ${installed} cannot be read (${err.message}); nothing was written.`);
99
+ }
100
+ const by = current?.installedBy;
101
+ if (!(by && typeof by === 'object' && String(by.tool ?? '').startsWith('dashboard-only install'))) {
102
+ throw new k.ServiceError(`${me} is installed by another installer (${installed}); nothing was written.`);
103
+ }
104
+ if (String(current.paths?.data ?? '') !== folder) {
105
+ throw new k.ServiceError(`${me} is installed with the folder ${current.paths?.data}, not ${folder}; nothing was written.`);
106
+ }
107
+ return { result: answer(installed, current), already: true };
108
+ }
109
+ if (a.label && !a.plist) throw new k.ServiceError("--label needs --plist, the launchd job's plist path.");
110
+ const repository = a.repository || gitOrigin(folder);
111
+ const ports = await candidates(services, me, a.port);
112
+ if (!ports.length) throw new k.ServiceError(`No free port in ${PORT_FIRST}-${PORT_LAST} that no other descriptor claims.`);
113
+ let last = null;
114
+ for (const port of ports) {
115
+ const d = build(a, folder, port, repository);
116
+ const problems = k.validateDescriptor(d);
117
+ const refusal = problems.length ? `Descriptor is invalid: ${problems.join('; ')}.` : k.sourceRefusal(d, services);
118
+ if (refusal) throw new k.ServiceError(refusal); // before any side effect
119
+ let file;
120
+ try { // the descriptor first: a port claim the kit refuses leaves no folder and no token behind
121
+ file = k.writeDescriptor(d, services);
122
+ } catch (err) {
123
+ if (!String(err.message).includes('already claimed') || a.port !== undefined) throw err;
124
+ last = err;
125
+ continue;
126
+ }
127
+ try {
128
+ k.ensurePrivateDir(folder);
129
+ k.ensureToken(path.join(folder, 'service.token')); // 0600, never printed
130
+ } catch (err) {
131
+ k.removeDescriptor(a.id, a.instance, services); // no descriptor names a token that is not there
132
+ throw err;
133
+ }
134
+ return { result: answer(file, d), already: false };
135
+ }
136
+ throw last;
137
+ }
138
+
139
+ async function main(argv) {
140
+ let a;
141
+ try {
142
+ const { values } = parseArgs({ args: argv, strict: true, options: {
143
+ id: { type: 'string' }, folder: { type: 'string' }, instance: { type: 'string', default: 'default' },
144
+ name: { type: 'string' }, repository: { type: 'string' }, port: { type: 'string' }, label: { type: 'string' },
145
+ plist: { type: 'string' }, 'services-dir': { type: 'string' }, json: { type: 'boolean', default: false } } });
146
+ if (!values.id || !values.folder) throw new Error('--id and --folder are required');
147
+ if (values.port !== undefined && !/^[0-9]{1,5}$/.test(values.port)) throw new Error('--port is a number');
148
+ a = { ...values, servicesDir: values['services-dir'], port: values.port === undefined ? undefined : Number(values.port) };
149
+ } catch (err) {
150
+ process.stderr.write(`usage: node install.mjs --id <id> --folder <folder> [--json] (${err.message})\n`);
151
+ return 2;
152
+ }
153
+ try {
154
+ const { result, already } = await install(a);
155
+ if (!a.json) process.stdout.write(`${already ? 'Already installed:' : 'Installed'} ${result.key} on port ${result.port}: ${result.descriptor}\n`);
156
+ process.stdout.write(`${line(result)}\n`);
157
+ return 0;
158
+ } catch (err) {
159
+ process.stderr.write(`${err.message}\n`);
160
+ return 1;
161
+ }
162
+ }
163
+
164
+ // Through a symlinked folder too (board thread b42e16b7): argv[1] is the link, import.meta.url the resolved file.
165
+ const invoked = (() => { try { return fs.realpathSync(process.argv[1] ?? ''); } catch { return null; } })();
166
+ if (invoked === fileURLToPath(import.meta.url)) {
167
+ process.exitCode = await main(process.argv.slice(2));
168
+ }
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """Install a dashboard-only fabric-service/0.1 service (FAA-268): a descriptor with a dashboard and nothing else.
3
+
4
+ python3 install.py --id <id> --folder <folder> [--instance default] [--name NAME] [--repository URI]
5
+ [--port N] [--label LABEL --plist PLIST] [--services-dir DIR] [--json]
6
+
7
+ The service has no capabilities, no provider keys and no token of anybody else: its page shows what the person's agent
8
+ reads through the Fabric Dashboards MCP (`list_services`, `spend`, `connections`) and Project Observatory and writes
9
+ into `<folder>/snapshot.json`. The installer picks a loopback port no other descriptor claims and that is free now,
10
+ writes the token file (0600) and the descriptor through the kit's `write_descriptor`, whose port claim check refuses a
11
+ port another descriptor declares. The last stdout line is `{"descriptor": <path>, "key": "<id>.<instance>", "port": <n>}`
12
+ (`--json` prints only it). A repeat with the same `--id` changes nothing and answers the same line. The HA-1 port claim
13
+ file per OS account is not written: its schema waits on the contract's CT-228.
14
+
15
+ Exit codes: 0 installed or already installed, 1 refused (the reason on stderr), 2 usage.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import socket
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+ from typing import Any, Dict, List, Optional
27
+
28
+ if str(Path(__file__).resolve().parent) not in sys.path: # the kit installed beside it, under `python3 -I` too
29
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
30
+ try:
31
+ import fabric_service as fs
32
+ except ImportError: # in the adapter's repository: the kit is two folders up
33
+ sys.path.append(str(Path(__file__).resolve().parents[2] / "scripts"))
34
+ import fabric_service as fs
35
+
36
+ VERSION = "0.1.0"
37
+ TOOL = "dashboard-only install %s" % VERSION
38
+ PORT_FIRST, PORT_LAST = 47300, 47999 # the recipe's range; a port another descriptor claims is skipped
39
+ ATTEMPTS = 8 # a port taken between the check and the write is retried on the next one
40
+
41
+
42
+ def claimed_ports(services: Optional[Path], me: str) -> set:
43
+ ports = set()
44
+ for _path, other in fs.read_descriptors(services):
45
+ if "%s.%s" % (other.get("id"), other.get("instance", "default")) == me or other.get("placement") == "remote":
46
+ continue
47
+ try:
48
+ ports.add(fs.port_of(str(other.get("origin", ""))))
49
+ except fs.ServiceError:
50
+ continue
51
+ return ports
52
+
53
+
54
+ def free_now(port: int) -> bool:
55
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
56
+ try:
57
+ s.bind(("127.0.0.1", port))
58
+ return True
59
+ except OSError:
60
+ return False
61
+
62
+
63
+ def candidates(services: Optional[Path], me: str, wanted: Optional[int]) -> List[int]:
64
+ if wanted is not None:
65
+ return [wanted]
66
+ taken = claimed_ports(services, me)
67
+ return [p for p in range(PORT_FIRST, PORT_LAST + 1) if p not in taken and free_now(p)][:ATTEMPTS]
68
+
69
+
70
+ def git_origin(folder: Path) -> Optional[str]:
71
+ """The folder's own `origin`, when it is an https URL the kit accepts as source.repository."""
72
+ try:
73
+ out = subprocess.run(["git", "-C", str(folder), "config", "--get", "remote.origin.url"],
74
+ capture_output=True, text=True, timeout=5)
75
+ except (OSError, subprocess.SubprocessError):
76
+ return None
77
+ url = out.stdout.strip()
78
+ if out.returncode != 0 or not url.startswith("https://"):
79
+ return None
80
+ return url[:-4] if url.endswith(".git") else url
81
+
82
+
83
+ def answer(descriptor_file: Path, descriptor: Dict[str, Any]) -> Dict[str, Any]:
84
+ return {"descriptor": str(descriptor_file), "key": "%s.%s" % (descriptor["id"], descriptor["instance"]),
85
+ "port": fs.port_of(descriptor["origin"])}
86
+
87
+
88
+ def build(args: argparse.Namespace, folder: Path, port: int, repository: Optional[str]) -> Dict[str, Any]:
89
+ descriptor: Dict[str, Any] = {
90
+ "protocol": fs.PROTOCOL, "id": args.id, "instance": args.instance, "name": args.name or args.id,
91
+ "summary": "A dashboard-only service: no capabilities, no provider keys.",
92
+ "origin": "http://127.0.0.1:%d" % port,
93
+ "auth": {"tokenFile": str(folder / "service.token")},
94
+ "lifecycle": {"manager": "launchd", "label": args.label, "plist": args.plist} if args.label else {"manager": "none"},
95
+ "paths": {"data": str(folder), "logs": []},
96
+ "installedAt": fs.now_iso(),
97
+ "installedBy": {"by": "person", "role": "operator", "tool": TOOL},
98
+ }
99
+ if repository:
100
+ descriptor["source"] = {"repository": repository}
101
+ return descriptor
102
+
103
+
104
+ def install(args: argparse.Namespace) -> Dict[str, Any]:
105
+ services = Path(args.services_dir).expanduser() if args.services_dir else None
106
+ folder = Path(args.folder).expanduser().resolve()
107
+ me = "%s.%s" % (args.id, args.instance)
108
+ installed = fs.descriptor_path(args.id, args.instance, services)
109
+ if installed.is_file():
110
+ try:
111
+ current = json.loads(installed.read_text(encoding="utf-8"))
112
+ except (OSError, ValueError) as exc:
113
+ raise fs.ServiceError("The installed descriptor %s cannot be read (%s); nothing was written." % (installed, exc))
114
+ by = current.get("installedBy") if isinstance(current, dict) else None
115
+ if not (isinstance(by, dict) and str(by.get("tool", "")).startswith("dashboard-only install")):
116
+ raise fs.ServiceError("%s is installed by another installer (%s); nothing was written." % (me, installed))
117
+ if Path(str((current.get("paths") or {}).get("data", ""))) != folder:
118
+ raise fs.ServiceError("%s is installed with the folder %s, not %s; nothing was written."
119
+ % (me, (current.get("paths") or {}).get("data"), folder))
120
+ return answer(installed, current)
121
+ if args.label and not args.plist:
122
+ raise fs.ServiceError("--label needs --plist, the launchd job's plist path.")
123
+ repository = args.repository or git_origin(folder)
124
+ ports = candidates(services, me, args.port)
125
+ if not ports:
126
+ raise fs.ServiceError("No free port in %d-%d that no other descriptor claims." % (PORT_FIRST, PORT_LAST))
127
+ last: Optional[Exception] = None
128
+ for port in ports:
129
+ descriptor = build(args, folder, port, repository)
130
+ problems = fs.validate_descriptor(descriptor)
131
+ refusal = ("Descriptor is invalid: %s." % "; ".join(problems) if problems else fs.source_refusal(descriptor, services))
132
+ if refusal: # before any side effect: a refused install leaves nothing behind
133
+ raise fs.ServiceError(refusal)
134
+ try: # the descriptor first: a port claim the kit refuses leaves no folder and no token behind
135
+ path = fs.write_descriptor(descriptor, services)
136
+ except fs.ServiceError as exc: # the kit's port claim: another descriptor declared it in between
137
+ if "already claimed" not in str(exc) or args.port is not None:
138
+ raise
139
+ last = exc
140
+ continue
141
+ try:
142
+ fs.ensure_private_dir(folder)
143
+ fs.ensure_token(folder / "service.token") # 0600, never printed
144
+ except (OSError, fs.ServiceError):
145
+ fs.remove_descriptor(args.id, args.instance, services) # no descriptor names a token that is not there
146
+ raise
147
+ return answer(path, descriptor)
148
+ raise fs.ServiceError(str(last))
149
+
150
+
151
+ def main(argv: Optional[List[str]] = None) -> int:
152
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
153
+ parser.add_argument("--id", required=True)
154
+ parser.add_argument("--folder", required=True, help="the service's own folder: token, data, snapshot")
155
+ parser.add_argument("--instance", default="default")
156
+ parser.add_argument("--name")
157
+ parser.add_argument("--repository", help="the repository its code is built from (default: the folder's https origin)")
158
+ parser.add_argument("--port", type=int, help="this port and no other")
159
+ parser.add_argument("--label", help="a launchd job's label, with --plist")
160
+ parser.add_argument("--plist")
161
+ parser.add_argument("--services-dir", help="the descriptor directory (default: FABRIC_SERVICES_DIR, else the OS's)")
162
+ parser.add_argument("--json", action="store_true", help="print only the JSON line")
163
+ args = parser.parse_args(argv)
164
+ try:
165
+ installed_before = fs.descriptor_path(args.id, args.instance,
166
+ Path(args.services_dir).expanduser() if args.services_dir else None).is_file()
167
+ result = install(args)
168
+ except (fs.ServiceError, OSError) as exc:
169
+ print(str(exc), file=sys.stderr)
170
+ return 1
171
+ if not args.json:
172
+ print("%s %s on port %d: %s" % ("Already installed:" if installed_before else "Installed", result["key"],
173
+ result["port"], result["descriptor"]))
174
+ print(json.dumps(result, ensure_ascii=False))
175
+ return 0
176
+
177
+
178
+ if __name__ == "__main__":
179
+ raise SystemExit(main())
@@ -0,0 +1,57 @@
1
+ # Recipe: a key-delivery door
2
+
3
+ A door is the command a service's owner ships so an agent gets a key for one recipient without anybody seeing it: it
4
+ asks the issuer for the key and hands it on sealed. This template (FAA-238) asks once, prints the key as one
5
+ `fabric-credential-envelope/1` (contract DEC-0111, [credentials](https://github.com/passioncode-ai/fabric-agent-contract/blob/main/docs/specification/credentials.md#the-envelope))
6
+ on stdout, and **writes into no store**: the receiver writes the recipient's slot. Twins: `door.py` and `door.mjs`,
7
+ the same flags, exit codes and line. Tests: `test/test_recipe_key_delivery_door.py` (both twins, a fake issuer and a
8
+ test sealer that encrypts nothing).
9
+
10
+ It follows Project Observatory's V2 spec (*Receive a key from an external issue door*, sections 3.2 and 8, agreed on
11
+ board thread `e7fbd27d`) until Observatory's OBS-360 ships `receive`: the receiver is
12
+
13
+ ```bash
14
+ python "$(project-observatory full-path)/tools/vault.py" receive PROJECT ENV NAME --from-command --json -- \
15
+ python3 door.py --slot EXAMPLE_KEY --provider example-issuer --agent-ref urn:fabric:agent:example:example-agent \
16
+ --recipient m-b#kem --context personal --issuer-command "example-issuer mint" --seal-command "example-sealer"
17
+ ```
18
+
19
+ so the agent names the door and the slot and sees only the receiver's answer, never the key. A plan that says the door
20
+ "writes into the recipient's slot" is met this way: the slot is written, by the receiver that runs the door. When
21
+ OBS-360 lands with other names, the door's line and exit codes stay; only the command above changes.
22
+
23
+ ## The two commands
24
+
25
+ The door owner supplies them; a value crosses only their stdin and stdout, in memory, and never argv, the environment,
26
+ a file or a log.
27
+
28
+ - `--issuer-command` reads the request on stdin (`{slot, provider, agentRef, context, requestId?}`: names only) and
29
+ prints `{"value": "<key>", "credentialExpiresAt"?: "<RFC 3339>"}`, or `{"refused": {"code": "<slug>"}}`. Exit 75
30
+ (`EX_TEMPFAIL`) is a transport failure; any other non-zero exit is a refusal.
31
+ - `--seal-command` reads `{"value", "header"}` (the header fields the door fixes: type, recipient, context, agentRef,
32
+ slot, provider, and in an organization's context requestId and credentialExpiresAt) and prints the envelope: HPKE
33
+ base mode suite `[16, 1, 1]` to the recipient's `#kem`, signed as DEC-0111 says (the contract's `sealCredential`; its
34
+ sequence per `(context, agentRef, slot)` is the sealer's to keep). The standard library has no HPKE, so the template
35
+ never seals by itself.
36
+
37
+ Before printing, the door checks the envelope against the schema's shape at the adapter's pin and against what it
38
+ asked for (the same slot, recipient, context, agentRef, provider and requestId; `exp` at most 24 hours after `iat`;
39
+ `credentialExpiresAt` at most 30 days, in an organization's context only), and refuses an envelope that carries the
40
+ value in clear. It decrypts and verifies nothing: that is the target's `checkCredentialEnvelope`. A child's stderr is
41
+ never forwarded, and the door's own messages never hold the value.
42
+
43
+ ## Exit codes
44
+
45
+ As Observatory's `receive --from-command` maps them (V2 section 8, from the access agent's door):
46
+
47
+ | Exit | Means | Observatory reads |
48
+ |---|---|---|
49
+ | 0 | delivered: the envelope is the one line on stdout | the envelope |
50
+ | 2 | usage: a flag missing or out of shape (an organization's context needs `--request-id`, a personal one takes none) | `issuer-usage` |
51
+ | 3 | the issuer refused (its code, when it gave a slug) | `issuer-refused` |
52
+ | 4 | no key in the issuer's answer | `issuer-no-key` |
53
+ | 5 | the store refused: the sealer refused, or its envelope fails the checks above | `store-refused` |
54
+ | 1 | a transport failure that outlived its retries | `issuer-command-failed` |
55
+
56
+ Only a transport failure is retried: three tries in all, one and then two seconds apart. A refusal, a missing key and a
57
+ sealing failure are answered at once, since asking again would ask the issuer for a second key.