@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +22 -0
  3. package/README.md +69 -0
  4. package/SECURITY.md +42 -0
  5. package/bin/passioncode.js +65 -0
  6. package/family.json +45 -0
  7. package/lib/launcher.js +361 -0
  8. package/package.json +48 -0
  9. package/payload/.claude-plugin/marketplace.json +47 -0
  10. package/payload/manifest.json +77 -0
  11. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +24 -0
  12. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +175 -0
  13. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/profile-selection.md +71 -0
  14. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-bundle.md +62 -0
  15. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +55 -0
  16. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +537 -0
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +240 -0
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +32 -0
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +44 -0
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +63 -0
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +32 -0
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +83 -0
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +58 -0
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +373 -0
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +380 -0
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +663 -0
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +226 -0
  28. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +126 -0
  29. package/payload/plugins/observatory-log/.claude-plugin/plugin.json +19 -0
  30. package/payload/plugins/observatory-log/hooks/ask-why.py +98 -0
  31. package/payload/plugins/observatory-log/hooks/hooks.json +31 -0
  32. package/payload/plugins/observatory-log/hooks/record-turn.sh +81 -0
  33. package/payload/plugins/observatory-log/hooks/session-start.sh +27 -0
  34. package/payload/plugins/observatory-log/skills/explaining-changes/SKILL.md +111 -0
  35. package/payload/plugins/observatory-log/skills/handling-secrets/SKILL.md +115 -0
  36. package/payload/plugins/observatory-log/skills/tracking-resources/SKILL.md +87 -0
  37. package/payload/plugins/passioncode/.claude-plugin/plugin.json +13 -0
  38. package/payload/plugins/passioncode/hooks/hooks.json +10 -0
  39. package/payload/plugins/passioncode/hooks/probe.js +23 -0
  40. package/payload/plugins/passioncode/hooks/session-start.js +45 -0
  41. package/payload/plugins/passioncode/hooks/update-check.js +126 -0
  42. 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("_")]