@passioncode-ai/passioncode 0.1.4
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 +73 -0
- package/LICENSE +22 -0
- package/README.md +69 -0
- package/SECURITY.md +42 -0
- package/bin/passioncode.js +65 -0
- package/family.json +45 -0
- package/lib/launcher.js +361 -0
- package/package.json +48 -0
- package/payload/.claude-plugin/marketplace.json +47 -0
- package/payload/manifest.json +77 -0
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +24 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +175 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/profile-selection.md +71 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-bundle.md +62 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +55 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +537 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +240 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +32 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +44 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +63 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +32 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +83 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +58 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +373 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +380 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +663 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +226 -0
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +126 -0
- package/payload/plugins/observatory-log/.claude-plugin/plugin.json +19 -0
- package/payload/plugins/observatory-log/hooks/ask-why.py +98 -0
- package/payload/plugins/observatory-log/hooks/hooks.json +31 -0
- package/payload/plugins/observatory-log/hooks/record-turn.sh +81 -0
- package/payload/plugins/observatory-log/hooks/session-start.sh +27 -0
- package/payload/plugins/observatory-log/skills/explaining-changes/SKILL.md +111 -0
- package/payload/plugins/observatory-log/skills/handling-secrets/SKILL.md +115 -0
- package/payload/plugins/observatory-log/skills/tracking-resources/SKILL.md +87 -0
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +13 -0
- package/payload/plugins/passioncode/hooks/hooks.json +10 -0
- package/payload/plugins/passioncode/hooks/probe.js +23 -0
- package/payload/plugins/passioncode/hooks/session-start.js +45 -0
- package/payload/plugins/passioncode/hooks/update-check.js +126 -0
- package/payload/plugins/passioncode/trust.json +6 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Reference kit for the fabric-service/0.1 local service extension.
|
|
3
|
+
|
|
4
|
+
Standard library only, Python 3.9+. Copy this file into a service (it is one
|
|
5
|
+
module on purpose) or import it by path. Every function implements one rule of
|
|
6
|
+
the extension; the rule it implements is named in its docstring, so a service
|
|
7
|
+
that calls these functions inherits the rule instead of re-deriving it.
|
|
8
|
+
|
|
9
|
+
Normative source: fabric-agent-contract docs/specification/service.md (DEC-0015).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import datetime as _dt
|
|
15
|
+
import errno
|
|
16
|
+
import hashlib
|
|
17
|
+
import hmac
|
|
18
|
+
import http.server
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
import plistlib
|
|
23
|
+
import re
|
|
24
|
+
import secrets
|
|
25
|
+
import socketserver
|
|
26
|
+
import stat
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
import time
|
|
31
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.request
|
|
34
|
+
|
|
35
|
+
PROTOCOL = "fabric-service/0.1"
|
|
36
|
+
EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/service/0.1"
|
|
37
|
+
EXIT_ALREADY_RUNNING = 75
|
|
38
|
+
LOGIN_CODE_TTL_SECONDS = 120
|
|
39
|
+
EVENTS_DEFAULT_LIMIT = 50
|
|
40
|
+
EVENTS_MAX_LIMIT = 200
|
|
41
|
+
LEVELS = ("info", "notice", "warning", "error")
|
|
42
|
+
STATUSES = ("starting", "ready", "degraded", "stopping")
|
|
43
|
+
|
|
44
|
+
_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
|
45
|
+
_INSTANCE = re.compile(r"^[a-z][a-z0-9-]{0,31}$")
|
|
46
|
+
_KIND = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,5}$")
|
|
47
|
+
_ORIGIN = re.compile(r"^http://127\.0\.0\.1:([0-9]{3,5})$")
|
|
48
|
+
_CODE = re.compile(r"^[A-Za-z0-9_-]{16,256}$")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ServiceError(Exception):
|
|
52
|
+
"""A rule of the extension was violated; the message is one readable sentence."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AlreadyRunning(ServiceError):
|
|
56
|
+
def __init__(self, holder_pid: Optional[int], lock_path: Path):
|
|
57
|
+
self.holder_pid = holder_pid
|
|
58
|
+
self.lock_path = lock_path
|
|
59
|
+
who = "process %d" % holder_pid if holder_pid else "another process"
|
|
60
|
+
super().__init__("Another copy is already running (%s holds %s)." % (who, lock_path))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def now_iso() -> str:
|
|
64
|
+
return _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def expand(path: str) -> Path:
|
|
68
|
+
"""Paths in a descriptor may start with ~/; everything else must be absolute."""
|
|
69
|
+
p = Path(os.path.expanduser(path))
|
|
70
|
+
if not p.is_absolute():
|
|
71
|
+
raise ServiceError("Path %r must be absolute or start with ~/." % path)
|
|
72
|
+
return p
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --- where things live -------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def services_dir() -> Path:
|
|
78
|
+
"""Descriptor directory: FABRIC_SERVICES_DIR, else the per-user OS location."""
|
|
79
|
+
override = os.environ.get("FABRIC_SERVICES_DIR")
|
|
80
|
+
if override:
|
|
81
|
+
return Path(override).expanduser()
|
|
82
|
+
home = Path.home()
|
|
83
|
+
if sys.platform == "darwin":
|
|
84
|
+
return home / "Library/Application Support/ai.passioncode.fabric/services"
|
|
85
|
+
base = os.environ.get("XDG_DATA_HOME") or str(home / ".local/share")
|
|
86
|
+
return Path(base) / "passioncode-fabric/services"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def service_dirs(service_id: str) -> Dict[str, Path]:
|
|
90
|
+
"""State lives outside code: data+config, logs and cache in per-user OS directories."""
|
|
91
|
+
_require_id(service_id)
|
|
92
|
+
home = Path.home()
|
|
93
|
+
if sys.platform == "darwin":
|
|
94
|
+
return {
|
|
95
|
+
"data": home / "Library/Application Support" / service_id,
|
|
96
|
+
"logs": home / "Library/Logs" / service_id,
|
|
97
|
+
"cache": home / "Library/Caches" / service_id,
|
|
98
|
+
}
|
|
99
|
+
data = Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share") / service_id
|
|
100
|
+
state = Path(os.environ.get("XDG_STATE_HOME") or home / ".local/state") / service_id
|
|
101
|
+
cache = Path(os.environ.get("XDG_CACHE_HOME") or home / ".cache") / service_id
|
|
102
|
+
return {"data": data, "logs": state / "logs", "cache": cache}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def ensure_private_dir(path: Path) -> Path:
|
|
106
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
os.chmod(path, 0o700)
|
|
108
|
+
return path
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def atomic_write(path: Path, data: bytes, mode: int = 0o600) -> None:
|
|
112
|
+
"""Temporary file in the same directory, fsync, rename: a reader never sees half a file."""
|
|
113
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
fd, tmp = tempfile.mkstemp(prefix="." + path.name + ".", dir=str(path.parent))
|
|
115
|
+
try:
|
|
116
|
+
with os.fdopen(fd, "wb") as handle:
|
|
117
|
+
handle.write(data)
|
|
118
|
+
handle.flush()
|
|
119
|
+
os.fsync(handle.fileno())
|
|
120
|
+
os.chmod(tmp, mode)
|
|
121
|
+
os.replace(tmp, path)
|
|
122
|
+
except BaseException:
|
|
123
|
+
try:
|
|
124
|
+
os.unlink(tmp)
|
|
125
|
+
except FileNotFoundError:
|
|
126
|
+
pass
|
|
127
|
+
raise
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --- one copy ----------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
class InstanceLock:
|
|
133
|
+
"""Exclusive flock on <data>/service.lock, taken BEFORE any side effect.
|
|
134
|
+
|
|
135
|
+
Binding a port is not a lock: a second copy that resumes jobs before its
|
|
136
|
+
bind fails has already done damage. The lock is released by the kernel when
|
|
137
|
+
the process dies, so a crash never leaves it stale.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(self, data_dir: Path):
|
|
141
|
+
self.path = Path(data_dir) / "service.lock"
|
|
142
|
+
self._fd: Optional[int] = None
|
|
143
|
+
|
|
144
|
+
def acquire(self) -> "InstanceLock":
|
|
145
|
+
import fcntl
|
|
146
|
+
|
|
147
|
+
ensure_private_dir(self.path.parent)
|
|
148
|
+
fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT, 0o600)
|
|
149
|
+
try:
|
|
150
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
151
|
+
except OSError as exc:
|
|
152
|
+
os.close(fd)
|
|
153
|
+
if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES):
|
|
154
|
+
raise AlreadyRunning(_read_pid(self.path), self.path) from None
|
|
155
|
+
raise
|
|
156
|
+
os.ftruncate(fd, 0)
|
|
157
|
+
os.write(fd, str(os.getpid()).encode())
|
|
158
|
+
os.fsync(fd)
|
|
159
|
+
self._fd = fd
|
|
160
|
+
return self
|
|
161
|
+
|
|
162
|
+
def release(self) -> None:
|
|
163
|
+
if self._fd is not None:
|
|
164
|
+
os.close(self._fd)
|
|
165
|
+
self._fd = None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _read_pid(path: Path) -> Optional[int]:
|
|
169
|
+
try:
|
|
170
|
+
text = path.read_text().strip()
|
|
171
|
+
return int(text) if re.fullmatch(r"[0-9]{1,10}", text, re.ASCII) else None
|
|
172
|
+
except OSError:
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def hold_single_instance(data_dir: Path) -> InstanceLock:
|
|
177
|
+
"""Take the lock or exit 75 with one sentence naming the holder."""
|
|
178
|
+
try:
|
|
179
|
+
return InstanceLock(data_dir).acquire()
|
|
180
|
+
except AlreadyRunning as exc:
|
|
181
|
+
print(str(exc), file=sys.stderr)
|
|
182
|
+
raise SystemExit(EXIT_ALREADY_RUNNING)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# --- token -------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def ensure_token(path: Path) -> str:
|
|
188
|
+
"""Create the service token once (0600, owner only); never rotate silently."""
|
|
189
|
+
path = Path(path)
|
|
190
|
+
if path.exists():
|
|
191
|
+
return read_token(path)
|
|
192
|
+
ensure_private_dir(path.parent)
|
|
193
|
+
token = secrets.token_urlsafe(32)
|
|
194
|
+
atomic_write(path, token.encode(), 0o600)
|
|
195
|
+
return token
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def read_token(path: Path) -> str:
|
|
199
|
+
"""Refuse a symlink, a file owned by someone else, or one others can read."""
|
|
200
|
+
path = Path(path)
|
|
201
|
+
info = os.lstat(path)
|
|
202
|
+
if stat.S_ISLNK(info.st_mode):
|
|
203
|
+
raise ServiceError("Token file %s is a symlink; refusing it." % path)
|
|
204
|
+
if info.st_uid != os.getuid():
|
|
205
|
+
raise ServiceError("Token file %s belongs to another user." % path)
|
|
206
|
+
if info.st_mode & 0o077:
|
|
207
|
+
raise ServiceError("Token file %s is readable by others; set mode 0600." % path)
|
|
208
|
+
token = path.read_text().strip()
|
|
209
|
+
if len(token) < 16:
|
|
210
|
+
raise ServiceError("Token file %s holds no usable token." % path)
|
|
211
|
+
return token
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def token_matches(presented: Optional[str], token: str, scheme: str = "Bearer") -> bool:
|
|
215
|
+
if not presented:
|
|
216
|
+
return False
|
|
217
|
+
value = presented
|
|
218
|
+
if scheme == "Bearer":
|
|
219
|
+
if not presented.startswith("Bearer "):
|
|
220
|
+
return False
|
|
221
|
+
value = presented[len("Bearer "):]
|
|
222
|
+
return hmac.compare_digest(value.strip().encode(), token.encode())
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# --- network guard -----------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def check_request(port: int, host: Optional[str], origin: Optional[str] = None,
|
|
228
|
+
sec_fetch_site: Optional[str] = None) -> Optional[str]:
|
|
229
|
+
"""Return why a request must be refused (403), or None when it may proceed."""
|
|
230
|
+
allowed_hosts = {"127.0.0.1:%d" % port, "localhost:%d" % port, "[::1]:%d" % port}
|
|
231
|
+
if host not in allowed_hosts:
|
|
232
|
+
return "Host %r is not this service." % host
|
|
233
|
+
if origin is not None and origin not in {"http://" + h for h in allowed_hosts}:
|
|
234
|
+
return "Origin %r is not this service." % origin
|
|
235
|
+
if sec_fetch_site == "cross-site":
|
|
236
|
+
return "Cross-site requests are refused."
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# --- descriptor --------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
class LoopbackHTTPServer(http.server.ThreadingHTTPServer):
|
|
243
|
+
"""ThreadingHTTPServer for a loopback service: threads are daemons, and the bind asks
|
|
244
|
+
no resolver. HTTPServer.server_bind() calls socket.getfqdn() between bind() and listen();
|
|
245
|
+
on a Mac with a slow resolver the port then stays bound but silent, so a host sees
|
|
246
|
+
neither an answer nor a refusal."""
|
|
247
|
+
|
|
248
|
+
daemon_threads = True
|
|
249
|
+
|
|
250
|
+
def server_bind(self) -> None:
|
|
251
|
+
socketserver.TCPServer.server_bind(self)
|
|
252
|
+
host, port = self.server_address[:2]
|
|
253
|
+
self.server_name, self.server_port = host, port
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def descriptor_path(service_id: str, instance: str = "default", directory: Optional[Path] = None) -> Path:
|
|
257
|
+
return (directory or services_dir()) / ("%s.%s.json" % (service_id, instance))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def read_descriptors(directory: Optional[Path] = None) -> List[Tuple[Path, Dict[str, Any]]]:
|
|
261
|
+
root = directory or services_dir()
|
|
262
|
+
found: List[Tuple[Path, Dict[str, Any]]] = []
|
|
263
|
+
if not root.is_dir():
|
|
264
|
+
return found
|
|
265
|
+
for path in sorted(root.glob("*.json")):
|
|
266
|
+
try:
|
|
267
|
+
found.append((path, json.loads(path.read_text(encoding="utf-8"))))
|
|
268
|
+
except (OSError, ValueError):
|
|
269
|
+
continue
|
|
270
|
+
return found
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
|
|
274
|
+
"""Structural checks mirroring service-descriptor.schema.json (the schema stays normative)."""
|
|
275
|
+
problems: List[str] = []
|
|
276
|
+
required = ("protocol", "id", "instance", "name", "origin", "auth", "lifecycle", "paths", "installedAt", "installedBy")
|
|
277
|
+
for key in required:
|
|
278
|
+
if key not in descriptor:
|
|
279
|
+
problems.append("missing %s" % key)
|
|
280
|
+
if problems:
|
|
281
|
+
return problems
|
|
282
|
+
if descriptor["protocol"] != PROTOCOL:
|
|
283
|
+
problems.append("protocol must be %s" % PROTOCOL)
|
|
284
|
+
if not _ID.match(str(descriptor["id"])):
|
|
285
|
+
problems.append("id must match %s" % _ID.pattern)
|
|
286
|
+
if not _INSTANCE.match(str(descriptor["instance"])):
|
|
287
|
+
problems.append("instance must match %s" % _INSTANCE.pattern)
|
|
288
|
+
if not _ORIGIN.match(str(descriptor["origin"])):
|
|
289
|
+
problems.append("origin must be http://127.0.0.1:<port>")
|
|
290
|
+
auth = descriptor.get("auth") or {}
|
|
291
|
+
if "tokenFile" not in auth:
|
|
292
|
+
problems.append("auth.tokenFile is required")
|
|
293
|
+
header = auth.get("header", "Authorization")
|
|
294
|
+
scheme = auth.get("scheme", "Bearer")
|
|
295
|
+
if header != "Authorization" and scheme != "none":
|
|
296
|
+
problems.append("a custom auth header carries the raw token: scheme must be none")
|
|
297
|
+
life = descriptor.get("lifecycle") or {}
|
|
298
|
+
if life.get("manager") not in ("launchd", "none"):
|
|
299
|
+
problems.append("lifecycle.manager must be launchd or none")
|
|
300
|
+
if life.get("manager") == "launchd" and not (life.get("label") and str(life.get("plist", "")).endswith(".plist")):
|
|
301
|
+
problems.append("a launchd service declares label and plist")
|
|
302
|
+
for name, argv in (descriptor.get("commands") or {}).items():
|
|
303
|
+
if name not in ("doctor", "update"):
|
|
304
|
+
problems.append("unknown command %s" % name)
|
|
305
|
+
elif not isinstance(argv, list) or not argv or not all(isinstance(a, str) for a in argv):
|
|
306
|
+
problems.append("command %s must be an argument array" % name)
|
|
307
|
+
elif not re.match(r"^(~/|/)", argv[0]):
|
|
308
|
+
problems.append("command %s must start with an absolute or ~/ executable" % name)
|
|
309
|
+
return problems
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def port_of(origin: str) -> int:
|
|
313
|
+
match = _ORIGIN.match(origin)
|
|
314
|
+
if not match:
|
|
315
|
+
raise ServiceError("Origin %r is not http://127.0.0.1:<port>." % origin)
|
|
316
|
+
return int(match.group(1))
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = None) -> Path:
|
|
320
|
+
"""Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim)."""
|
|
321
|
+
problems = validate_descriptor(descriptor)
|
|
322
|
+
if problems:
|
|
323
|
+
raise ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
|
|
324
|
+
root = directory or services_dir()
|
|
325
|
+
me = "%s.%s" % (descriptor["id"], descriptor["instance"])
|
|
326
|
+
port = port_of(descriptor["origin"])
|
|
327
|
+
for path, other in read_descriptors(root):
|
|
328
|
+
key = "%s.%s" % (other.get("id"), other.get("instance", "default"))
|
|
329
|
+
if key == me:
|
|
330
|
+
continue
|
|
331
|
+
try:
|
|
332
|
+
other_port = port_of(str(other.get("origin", "")))
|
|
333
|
+
except ServiceError:
|
|
334
|
+
continue
|
|
335
|
+
if other_port == port:
|
|
336
|
+
raise ServiceError("Port %d is already claimed by %s (%s)." % (port, key, path))
|
|
337
|
+
ensure_private_dir(root)
|
|
338
|
+
target = descriptor_path(descriptor["id"], descriptor["instance"], root)
|
|
339
|
+
atomic_write(target, (json.dumps(descriptor, indent=2, ensure_ascii=False) + "\n").encode(), 0o600)
|
|
340
|
+
return target
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def remove_descriptor(service_id: str, instance: str = "default", directory: Optional[Path] = None) -> bool:
|
|
344
|
+
try:
|
|
345
|
+
descriptor_path(service_id, instance, directory).unlink()
|
|
346
|
+
return True
|
|
347
|
+
except FileNotFoundError:
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# --- build identity & well-known ------------------------------------------------
|
|
352
|
+
|
|
353
|
+
def build_info(repo_root: Optional[Path] = None, package_file: Optional[Path] = None) -> Dict[str, Any]:
|
|
354
|
+
"""Commit (env FABRIC_BUILD_COMMIT, else git) or a sha256 digest of the package file."""
|
|
355
|
+
commit = os.environ.get("FABRIC_BUILD_COMMIT")
|
|
356
|
+
dirty: Optional[bool] = None
|
|
357
|
+
if not commit and repo_root is not None:
|
|
358
|
+
try:
|
|
359
|
+
commit = subprocess.run(["git", "-C", str(repo_root), "rev-parse", "--short=12", "HEAD"],
|
|
360
|
+
capture_output=True, text=True, timeout=5, check=True).stdout.strip()
|
|
361
|
+
status = subprocess.run(["git", "-C", str(repo_root), "status", "--porcelain", "--untracked-files=no"],
|
|
362
|
+
capture_output=True, text=True, timeout=5, check=True).stdout
|
|
363
|
+
dirty = bool(status.strip())
|
|
364
|
+
except (OSError, subprocess.SubprocessError):
|
|
365
|
+
commit = None
|
|
366
|
+
info: Dict[str, Any] = {"builtAt": now_iso()}
|
|
367
|
+
if commit:
|
|
368
|
+
info["commit"] = commit
|
|
369
|
+
if dirty is not None:
|
|
370
|
+
info["dirty"] = dirty
|
|
371
|
+
elif package_file is not None and Path(package_file).is_file():
|
|
372
|
+
info["digest"] = "sha256:" + hashlib.sha256(Path(package_file).read_bytes()).hexdigest()
|
|
373
|
+
else:
|
|
374
|
+
raise ServiceError("No build identity: set FABRIC_BUILD_COMMIT, pass repo_root or package_file.")
|
|
375
|
+
return info
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def build_well_known(*, service_id: str, instance: str, name: str, version: str, build: Dict[str, Any],
|
|
379
|
+
started_at: str, status: str, degraded: Sequence[Dict[str, str]],
|
|
380
|
+
surfaces: Dict[str, Any], summary: Optional[Sequence[Dict[str, Any]]] = None,
|
|
381
|
+
update_available: Optional[str] = None, pid: Optional[int] = None) -> Dict[str, Any]:
|
|
382
|
+
"""The unauthenticated identity answer. `degraded` is always present; ready means empty."""
|
|
383
|
+
if status not in STATUSES:
|
|
384
|
+
raise ServiceError("status must be one of %s." % ", ".join(STATUSES))
|
|
385
|
+
degraded = list(degraded)
|
|
386
|
+
if status == "ready" and degraded:
|
|
387
|
+
status = "degraded"
|
|
388
|
+
if "events" not in surfaces:
|
|
389
|
+
raise ServiceError("surfaces.events is required.")
|
|
390
|
+
doc: Dict[str, Any] = {
|
|
391
|
+
"protocol": PROTOCOL,
|
|
392
|
+
"service": {"id": service_id, "instance": instance, "name": name, "version": version, "build": build},
|
|
393
|
+
"process": {"pid": pid or os.getpid(), "startedAt": started_at},
|
|
394
|
+
"status": status,
|
|
395
|
+
"degraded": degraded,
|
|
396
|
+
"surfaces": surfaces,
|
|
397
|
+
"update": {"available": update_available},
|
|
398
|
+
}
|
|
399
|
+
if summary:
|
|
400
|
+
doc["summary"] = list(summary)[:6]
|
|
401
|
+
return doc
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# --- events ------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subject: Optional[Dict[str, str]] = None,
|
|
407
|
+
link: Optional[str] = None, notify: bool = False) -> Dict[str, Any]:
|
|
408
|
+
"""One activity event: one sentence a person reads, never a machine id."""
|
|
409
|
+
if level not in LEVELS:
|
|
410
|
+
raise ServiceError("level must be one of %s." % ", ".join(LEVELS))
|
|
411
|
+
if not _KIND.match(kind):
|
|
412
|
+
raise ServiceError("kind %r must be dotted lowercase." % kind)
|
|
413
|
+
text = " ".join(str(text).split())
|
|
414
|
+
if not text:
|
|
415
|
+
raise ServiceError("An event needs a sentence.")
|
|
416
|
+
if link is not None and (not link.startswith("/") or link.startswith("//")):
|
|
417
|
+
raise ServiceError("link must be a path on this service.")
|
|
418
|
+
event: Dict[str, Any] = {"id": str(event_id), "at": at, "kind": kind, "level": level, "text": text[:500]}
|
|
419
|
+
if subject:
|
|
420
|
+
event["subject"] = subject
|
|
421
|
+
if link:
|
|
422
|
+
event["link"] = link
|
|
423
|
+
if notify:
|
|
424
|
+
event["notify"] = True
|
|
425
|
+
return event
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def parse_limit(raw: Optional[str]) -> int:
|
|
429
|
+
try:
|
|
430
|
+
value = int(raw) if raw not in (None, "") else EVENTS_DEFAULT_LIMIT
|
|
431
|
+
except ValueError:
|
|
432
|
+
raise ServiceError("limit must be an integer.") from None
|
|
433
|
+
return max(1, min(EVENTS_MAX_LIMIT, value))
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def events_page(fetch: Callable[[Optional[str], int], Iterable[Dict[str, Any]]],
|
|
437
|
+
after: Optional[str], limit: int) -> Dict[str, Any]:
|
|
438
|
+
"""A cursor page over the log the service already keeps.
|
|
439
|
+
|
|
440
|
+
`fetch(after, limit)` returns events with ids greater than `after` in
|
|
441
|
+
ascending order (or the newest `limit`, ascending, when `after` is None).
|
|
442
|
+
"""
|
|
443
|
+
events = list(fetch(after, limit))[:limit]
|
|
444
|
+
cursor = events[-1]["id"] if events else (after or None)
|
|
445
|
+
return {"events": events, "cursor": cursor}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class JsonlEventLog:
|
|
449
|
+
"""A minimal append-only log for services that keep none yet. Ids are integers as strings."""
|
|
450
|
+
|
|
451
|
+
def __init__(self, path: Path, keep: int = 5000):
|
|
452
|
+
self.path = Path(path)
|
|
453
|
+
self.keep = keep
|
|
454
|
+
|
|
455
|
+
def _all(self) -> List[Dict[str, Any]]:
|
|
456
|
+
if not self.path.exists():
|
|
457
|
+
return []
|
|
458
|
+
out = []
|
|
459
|
+
for line in self.path.read_text(encoding="utf-8").splitlines():
|
|
460
|
+
try:
|
|
461
|
+
out.append(json.loads(line))
|
|
462
|
+
except ValueError:
|
|
463
|
+
continue
|
|
464
|
+
return out
|
|
465
|
+
|
|
466
|
+
def append(self, kind: str, level: str, text: str, **extra: Any) -> Dict[str, Any]:
|
|
467
|
+
rows = self._all()
|
|
468
|
+
next_id = int(rows[-1]["id"]) + 1 if rows else 1
|
|
469
|
+
event = make_event(next_id, now_iso(), kind, level, text, **extra)
|
|
470
|
+
rows.append(event)
|
|
471
|
+
rows = rows[-self.keep:]
|
|
472
|
+
atomic_write(self.path, ("\n".join(json.dumps(r, ensure_ascii=False) for r in rows) + "\n").encode(), 0o600)
|
|
473
|
+
return event
|
|
474
|
+
|
|
475
|
+
def fetch(self, after: Optional[str], limit: int) -> List[Dict[str, Any]]:
|
|
476
|
+
rows = self._all()
|
|
477
|
+
if after is None:
|
|
478
|
+
return rows[-limit:]
|
|
479
|
+
try:
|
|
480
|
+
floor = int(after)
|
|
481
|
+
except ValueError:
|
|
482
|
+
raise ServiceError("cursor %r is not from this service." % after) from None
|
|
483
|
+
return [r for r in rows if int(r["id"]) > floor][:limit]
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# --- operator login ----------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
class LoginCodes:
|
|
489
|
+
"""Single-use login codes (<=120 s), recorded as used BEFORE they are honoured,
|
|
490
|
+
and HMAC-signed session cookies revoked by rotating the key."""
|
|
491
|
+
|
|
492
|
+
def __init__(self, state_dir: Path, ttl: int = LOGIN_CODE_TTL_SECONDS):
|
|
493
|
+
self.dir = ensure_private_dir(Path(state_dir))
|
|
494
|
+
self.ttl = min(ttl, LOGIN_CODE_TTL_SECONDS)
|
|
495
|
+
self._key_path = self.dir / "session.key"
|
|
496
|
+
self._codes_path = self.dir / "login-codes.json"
|
|
497
|
+
|
|
498
|
+
def _key(self) -> bytes:
|
|
499
|
+
if not self._key_path.exists():
|
|
500
|
+
atomic_write(self._key_path, secrets.token_bytes(32), 0o600)
|
|
501
|
+
return self._key_path.read_bytes()
|
|
502
|
+
|
|
503
|
+
def _load(self) -> Dict[str, Any]:
|
|
504
|
+
try:
|
|
505
|
+
return json.loads(self._codes_path.read_text())
|
|
506
|
+
except (OSError, ValueError):
|
|
507
|
+
return {}
|
|
508
|
+
|
|
509
|
+
def _save(self, codes: Dict[str, Any]) -> None:
|
|
510
|
+
horizon = time.time() - 3600
|
|
511
|
+
codes = {k: v for k, v in codes.items() if v.get("expires", 0) > horizon}
|
|
512
|
+
atomic_write(self._codes_path, json.dumps(codes).encode(), 0o600)
|
|
513
|
+
|
|
514
|
+
def issue(self) -> Dict[str, str]:
|
|
515
|
+
code = secrets.token_urlsafe(24)
|
|
516
|
+
expires = time.time() + self.ttl
|
|
517
|
+
codes = self._load()
|
|
518
|
+
codes[hashlib.sha256(code.encode()).hexdigest()] = {"expires": expires, "used": False}
|
|
519
|
+
self._save(codes)
|
|
520
|
+
at = _dt.datetime.fromtimestamp(expires, _dt.timezone.utc).replace(microsecond=0)
|
|
521
|
+
return {"url": "/fabric/v1/login?code=" + code, "expiresAt": at.isoformat().replace("+00:00", "Z")}
|
|
522
|
+
|
|
523
|
+
def redeem(self, code: Optional[str]) -> Optional[str]:
|
|
524
|
+
"""Return a session cookie value, or None. The code is burned even when it has expired."""
|
|
525
|
+
if not code or not _CODE.match(code):
|
|
526
|
+
return None
|
|
527
|
+
digest = hashlib.sha256(code.encode()).hexdigest()
|
|
528
|
+
codes = self._load()
|
|
529
|
+
entry = codes.get(digest)
|
|
530
|
+
if not entry or entry.get("used"):
|
|
531
|
+
return None
|
|
532
|
+
entry["used"] = True
|
|
533
|
+
self._save(codes)
|
|
534
|
+
if entry.get("expires", 0) < time.time():
|
|
535
|
+
return None
|
|
536
|
+
return self._sign(secrets.token_urlsafe(18))
|
|
537
|
+
|
|
538
|
+
def _sign(self, session_id: str) -> str:
|
|
539
|
+
mac = hmac.new(self._key(), session_id.encode(), hashlib.sha256).hexdigest()
|
|
540
|
+
return session_id + "." + mac
|
|
541
|
+
|
|
542
|
+
def session_valid(self, cookie_value: Optional[str]) -> bool:
|
|
543
|
+
if not cookie_value or "." not in cookie_value:
|
|
544
|
+
return False
|
|
545
|
+
session_id, mac = cookie_value.rsplit(".", 1)
|
|
546
|
+
expected = hmac.new(self._key(), session_id.encode(), hashlib.sha256).hexdigest()
|
|
547
|
+
return hmac.compare_digest(mac, expected)
|
|
548
|
+
|
|
549
|
+
def revoke_all(self) -> None:
|
|
550
|
+
atomic_write(self._key_path, secrets.token_bytes(32), 0o600)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
SESSION_COOKIE = "fabric_session"
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def session_cookie_header(value: str, max_age: int = 30 * 86400) -> str:
|
|
557
|
+
return "%s=%s; Path=/; HttpOnly; SameSite=Strict; Max-Age=%d" % (SESSION_COOKIE, value, max_age)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def cookie_value(cookie_header: Optional[str], name: str = SESSION_COOKIE) -> Optional[str]:
|
|
561
|
+
for part in (cookie_header or "").split(";"):
|
|
562
|
+
key, _, value = part.strip().partition("=")
|
|
563
|
+
if key == name:
|
|
564
|
+
return value
|
|
565
|
+
return None
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# --- launchd -----------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
def launchd_plist(label: str, program_arguments: Sequence[str], *, working_directory: Path,
|
|
571
|
+
stdout_path: Path, environment: Optional[Dict[str, str]] = None,
|
|
572
|
+
exit_timeout: int = 40) -> bytes:
|
|
573
|
+
"""RunAtLoad + KeepAlive true + ThrottleInterval 10; no secret may appear in `environment`."""
|
|
574
|
+
env = dict(environment or {})
|
|
575
|
+
for key in env:
|
|
576
|
+
if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)$", key) and not key.endswith("_FILE"):
|
|
577
|
+
raise ServiceError("Environment variable %s looks like a secret; pass a *_FILE path instead." % key)
|
|
578
|
+
env.setdefault("PATH", "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin")
|
|
579
|
+
plist = {
|
|
580
|
+
"Label": label,
|
|
581
|
+
"ProgramArguments": list(program_arguments),
|
|
582
|
+
"WorkingDirectory": str(working_directory),
|
|
583
|
+
"RunAtLoad": True,
|
|
584
|
+
"KeepAlive": True,
|
|
585
|
+
"ThrottleInterval": 10,
|
|
586
|
+
"ExitTimeOut": exit_timeout,
|
|
587
|
+
"ProcessType": "Background",
|
|
588
|
+
"StandardOutPath": str(stdout_path),
|
|
589
|
+
"StandardErrorPath": str(stdout_path),
|
|
590
|
+
"EnvironmentVariables": env,
|
|
591
|
+
}
|
|
592
|
+
return plistlib.dumps(plist)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _launchctl(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
|
|
596
|
+
return subprocess.run(["launchctl", *args], capture_output=True, text=True, timeout=timeout)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _domain() -> str:
|
|
600
|
+
return "gui/%d" % os.getuid()
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def launchd_loaded(label: str) -> bool:
|
|
604
|
+
return _launchctl("print", "%s/%s" % (_domain(), label)).returncode == 0
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def fetch_well_known(origin: str, timeout: float = 2.0) -> Optional[Dict[str, Any]]:
|
|
608
|
+
try:
|
|
609
|
+
with urllib.request.urlopen(origin + "/.well-known/fabric-service", timeout=timeout) as response:
|
|
610
|
+
return json.loads(response.read().decode())
|
|
611
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
612
|
+
return None
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin: str,
|
|
616
|
+
service_id: str, instance: str = "default", timeout: float = 40.0) -> Dict[str, Any]:
|
|
617
|
+
"""Write + lint the plist, bootout and WAIT for unload, bootstrap (retry EIO 5), then
|
|
618
|
+
poll the well-known document until it answers with this identity."""
|
|
619
|
+
plist_path = Path(plist_path)
|
|
620
|
+
atomic_write(plist_path, plist_bytes, 0o644)
|
|
621
|
+
lint = subprocess.run(["plutil", "-lint", str(plist_path)], capture_output=True, text=True)
|
|
622
|
+
if lint.returncode != 0:
|
|
623
|
+
raise ServiceError("plist failed plutil -lint: %s" % lint.stdout.strip())
|
|
624
|
+
target = "%s/%s" % (_domain(), label)
|
|
625
|
+
_launchctl("enable", target)
|
|
626
|
+
_launchctl("bootout", target)
|
|
627
|
+
deadline = time.time() + 15
|
|
628
|
+
while launchd_loaded(label) and time.time() < deadline:
|
|
629
|
+
time.sleep(0.25)
|
|
630
|
+
last = ""
|
|
631
|
+
for _ in range(5):
|
|
632
|
+
result = _launchctl("bootstrap", _domain(), str(plist_path))
|
|
633
|
+
if result.returncode == 0:
|
|
634
|
+
break
|
|
635
|
+
last = (result.stderr or result.stdout).strip()
|
|
636
|
+
time.sleep(1.0)
|
|
637
|
+
else:
|
|
638
|
+
raise ServiceError("launchctl bootstrap failed: %s" % last)
|
|
639
|
+
deadline = time.time() + timeout
|
|
640
|
+
while time.time() < deadline:
|
|
641
|
+
doc = fetch_well_known(origin)
|
|
642
|
+
if doc and doc.get("service", {}).get("id") == service_id and doc.get("service", {}).get("instance") == instance:
|
|
643
|
+
return doc
|
|
644
|
+
time.sleep(0.5)
|
|
645
|
+
raise ServiceError("%s did not answer as %s.%s within %.0f s; read its log." % (origin, service_id, instance, timeout))
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def launchd_uninstall(label: str, plist_path: Path) -> None:
|
|
649
|
+
_launchctl("bootout", "%s/%s" % (_domain(), label))
|
|
650
|
+
try:
|
|
651
|
+
Path(plist_path).unlink()
|
|
652
|
+
except FileNotFoundError:
|
|
653
|
+
pass
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# --- helpers for input validation -----------------------------------------------
|
|
657
|
+
|
|
658
|
+
def _require_id(service_id: str) -> None:
|
|
659
|
+
if not _ID.match(service_id):
|
|
660
|
+
raise ServiceError("Service id %r must match %s." % (service_id, _ID.pattern))
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
__all__ = [name for name in dir() if not name.startswith("_")]
|