@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.
- package/CHANGELOG.md +256 -0
- package/README.md +14 -7
- package/bin/fabric-agent-adapter.js +26 -4
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +42 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +12 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +23 -8
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +6 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +34 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +11 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +42 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +26 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +14 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +12 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +10 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +15 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +48 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +9 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +8 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +55 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +49 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +9 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +17 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +24 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +543 -37
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +12 -5
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +42 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +13 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +32 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +12 -4
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -26
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +59 -38
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +52 -26
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +169 -46
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +146 -19
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
- 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.
|