@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,373 @@
1
+ #!/usr/bin/env python3
2
+ """Live conformance probe for a fabric-service/0.1 service.
3
+
4
+ check_service.py <id>[.<instance>] # find the descriptor in the services directory
5
+ check_service.py --descriptor PATH
6
+ options: --services-dir DIR --json --skip-login
7
+
8
+ Every rule gets PASS, FAIL or NOT_RUN with its evidence. Exit 0 when nothing
9
+ FAILs, 1 when something does, 2 on a usage error. The probe only reads, except
10
+ that it redeems one login code it asked for itself (that creates one session).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import errno
17
+ import http.client
18
+ import json
19
+ import os
20
+ from pathlib import Path
21
+ import plistlib
22
+ import re
23
+ import shutil
24
+ import stat
25
+ import subprocess
26
+ import sys
27
+ import time
28
+ from typing import Any, Dict, List, Optional, Tuple
29
+
30
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
31
+ import fabric_service as fs # noqa: E402
32
+
33
+ Result = Dict[str, str]
34
+
35
+
36
+ class Probe:
37
+ def __init__(self, descriptor_path: Path, descriptor: Dict[str, Any], services_dir: Path, skip_login: bool):
38
+ self.path = descriptor_path
39
+ self.d = descriptor
40
+ self.dir = services_dir
41
+ self.skip_login = skip_login
42
+ self.results: List[Result] = []
43
+ self.wk: Optional[Dict[str, Any]] = None
44
+ self.token: Optional[str] = None
45
+ try:
46
+ self.port = fs.port_of(str(descriptor.get("origin", "")))
47
+ except fs.ServiceError:
48
+ self.port = 0
49
+
50
+ def add(self, rule: str, verdict: str, evidence: str) -> None:
51
+ self.results.append({"rule": rule, "verdict": verdict, "evidence": evidence})
52
+
53
+ def request(self, method: str, path: str, headers: Optional[Dict[str, str]] = None,
54
+ body: Optional[bytes] = None) -> Tuple[int, Dict[str, str], bytes]:
55
+ conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
56
+ base = {"Host": "127.0.0.1:%d" % self.port}
57
+ base.update(headers or {})
58
+ try:
59
+ conn.request(method, path, body=body, headers=base)
60
+ resp = conn.getresponse()
61
+ return resp.status, {k.lower(): v for k, v in resp.getheaders()}, resp.read()
62
+ finally:
63
+ conn.close()
64
+
65
+ def auth_headers(self) -> Dict[str, str]:
66
+ auth = self.d.get("auth", {})
67
+ header = auth.get("header", "Authorization")
68
+ value = self.token or ""
69
+ return {header: ("Bearer " + value) if auth.get("scheme", "Bearer") == "Bearer" else value}
70
+
71
+ # descriptor ------------------------------------------------------------
72
+ def descriptor_rules(self) -> None:
73
+ problems = fs.validate_descriptor(self.d)
74
+ self.add("descriptor.valid", "FAIL" if problems else "PASS", "; ".join(problems) or str(self.path))
75
+ mode = stat.S_IMODE(os.stat(self.path).st_mode)
76
+ self.add("descriptor.private", "PASS" if mode & 0o077 == 0 else "FAIL", "mode %o" % mode)
77
+ me = "%s.%s" % (self.d.get("id"), self.d.get("instance", "default"))
78
+ clashes = []
79
+ for path, other in fs.read_descriptors(self.dir):
80
+ key = "%s.%s" % (other.get("id"), other.get("instance", "default"))
81
+ if path.resolve() == self.path.resolve():
82
+ continue
83
+ if key == me:
84
+ clashes.append("%s declared again in %s" % (me, path.name))
85
+ elif other.get("origin") == self.d.get("origin"):
86
+ clashes.append("port %d also claimed by %s" % (self.port, key))
87
+ self.add("descriptor.port-claim", "FAIL" if clashes else "PASS", "; ".join(clashes) or "port %d is unique" % self.port)
88
+
89
+ # well-known ----------------------------------------------------------------
90
+ def well_known_rules(self) -> None:
91
+ try:
92
+ timings = []
93
+ for _ in range(3):
94
+ started = time.perf_counter()
95
+ status, headers, body = self.request("GET", "/.well-known/fabric-service")
96
+ timings.append((time.perf_counter() - started) * 1000)
97
+ except OSError as exc:
98
+ self.add("well-known.answers", "FAIL", "no answer on %s: %s" % (self.d.get("origin"), exc))
99
+ return
100
+ if status != 200:
101
+ self.add("well-known.answers", "FAIL", "HTTP %d" % status)
102
+ return
103
+ try:
104
+ self.wk = json.loads(body)
105
+ except ValueError:
106
+ self.add("well-known.answers", "FAIL", "body is not JSON")
107
+ return
108
+ self.add("well-known.answers", "PASS", "HTTP 200")
109
+ median = sorted(timings)[1]
110
+ self.add("well-known.fast", "PASS" if median < 100 else "FAIL", "median %.1f ms" % median)
111
+ wk = self.wk
112
+ problems = []
113
+ if wk.get("protocol") != fs.PROTOCOL:
114
+ problems.append("protocol %r" % wk.get("protocol"))
115
+ if "degraded" not in wk:
116
+ problems.append("degraded missing")
117
+ build = (wk.get("service") or {}).get("build") or {}
118
+ if not (build.get("commit") or build.get("digest")):
119
+ problems.append("build has neither commit nor digest")
120
+ if wk.get("status") not in fs.STATUSES:
121
+ problems.append("status %r" % wk.get("status"))
122
+ if "events" not in (wk.get("surfaces") or {}):
123
+ problems.append("surfaces.events missing")
124
+ self.add("well-known.shape", "FAIL" if problems else "PASS", "; ".join(problems) or "protocol, build, status, degraded, surfaces")
125
+ svc = wk.get("service") or {}
126
+ answered = "%s.%s" % (svc.get("id"), svc.get("instance"))
127
+ expected = "%s.%s" % (self.d.get("id"), self.d.get("instance", "default"))
128
+ self.add("well-known.identity", "PASS" if answered == expected else "FAIL",
129
+ "answers as %s" % answered + ("" if answered == expected else ", descriptor says %s" % expected))
130
+ ready_bad = wk.get("status") == "ready" and bool(wk.get("degraded"))
131
+ self.add("well-known.ready-means-healthy", "FAIL" if ready_bad else "PASS",
132
+ "status %s, %d degraded" % (wk.get("status"), len(wk.get("degraded") or [])))
133
+
134
+ # network ------------------------------------------------------------------
135
+ def network_rules(self) -> None:
136
+ for rule, headers in (
137
+ ("network.host-check", {"Host": "evil.example"}),
138
+ ("network.origin-check", {"Origin": "http://evil.example"}),
139
+ ("network.cross-site-check", {"Sec-Fetch-Site": "cross-site"}),
140
+ ):
141
+ try:
142
+ status, _, _ = self.request("GET", "/.well-known/fabric-service", headers)
143
+ self.add(rule, "PASS" if status == 403 else "FAIL", "HTTP %d" % status)
144
+ except OSError as exc:
145
+ self.add(rule, "NOT_RUN", str(exc))
146
+ if not shutil.which("lsof"):
147
+ self.add("network.loopback-only", "NOT_RUN", "lsof is not installed")
148
+ return
149
+ out = subprocess.run(["lsof", "-nP", "-iTCP:%d" % self.port, "-sTCP:LISTEN"], capture_output=True, text=True).stdout
150
+ names = re.findall(r"TCP (\S+) \(LISTEN\)", out)
151
+ wide = [n for n in names if not n.startswith(("127.0.0.1:", "[::1]:", "localhost:"))]
152
+ self.add("network.loopback-only", "FAIL" if wide or not names else "PASS",
153
+ ", ".join(names) or "nothing listens on %d" % self.port)
154
+
155
+ # auth, events, login ----------------------------------------------------------
156
+ def auth_rules(self) -> None:
157
+ token_file = str((self.d.get("auth") or {}).get("tokenFile", ""))
158
+ try:
159
+ self.token = fs.read_token(fs.expand(token_file))
160
+ self.add("auth.token-file", "PASS", "%s is 0600 and owned by you" % token_file)
161
+ except (fs.ServiceError, OSError) as exc:
162
+ self.add("auth.token-file", "FAIL", str(exc))
163
+ events_path = ((self.wk or {}).get("surfaces") or {}).get("events", {}).get("path", "/fabric/v1/events")
164
+ try:
165
+ status, _, _ = self.request("GET", events_path + "?limit=1")
166
+ self.add("events.requires-token", "PASS" if status == 401 else "FAIL", "HTTP %d without a token" % status)
167
+ except OSError as exc:
168
+ self.add("events.requires-token", "NOT_RUN", str(exc))
169
+ if not self.token:
170
+ self.add("events.page", "NOT_RUN", "no readable token")
171
+ return
172
+ try:
173
+ status, _, body = self.request("GET", events_path + "?limit=5", self.auth_headers())
174
+ except OSError as exc:
175
+ self.add("events.page", "NOT_RUN", str(exc))
176
+ return
177
+ if status != 200:
178
+ self.add("events.page", "FAIL", "HTTP %d with the token" % status)
179
+ return
180
+ try:
181
+ page = json.loads(body)
182
+ events = page["events"]
183
+ assert "cursor" in page
184
+ except (ValueError, KeyError, AssertionError):
185
+ self.add("events.page", "FAIL", "not an events page")
186
+ return
187
+ bad = []
188
+ for e in events:
189
+ if e.get("level") not in fs.LEVELS or not re.match(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$", str(e.get("kind", ""))):
190
+ bad.append(str(e.get("id")))
191
+ elif " " not in str(e.get("text", "")).strip():
192
+ bad.append("%s (text is not a sentence)" % e.get("id"))
193
+ self.add("events.page", "FAIL" if bad else "PASS",
194
+ ("bad events: " + ", ".join(bad)) if bad else "%d event(s), cursor %r" % (len(events), page["cursor"]))
195
+
196
+ def login_rules(self) -> None:
197
+ dashboard = ((self.wk or {}).get("surfaces") or {}).get("dashboard")
198
+ if not dashboard or not dashboard.get("login"):
199
+ self.add("login.single-use", "NOT_RUN", "dashboard declares no login")
200
+ return
201
+ if self.skip_login or not self.token:
202
+ self.add("login.single-use", "NOT_RUN", "--skip-login" if self.skip_login else "no readable token")
203
+ return
204
+ try:
205
+ status, _, body = self.request("POST", "/fabric/v1/login-code", self.auth_headers())
206
+ code = json.loads(body)
207
+ url = code["url"]
208
+ status1, headers1, _ = self.request("GET", url)
209
+ status2, _, _ = self.request("GET", url)
210
+ except (OSError, ValueError, KeyError) as exc:
211
+ self.add("login.single-use", "FAIL", "login-code flow broke: %s" % exc)
212
+ return
213
+ cookie = headers1.get("set-cookie", "")
214
+ ok = status1 in (302, 303) and "HttpOnly" in cookie and "SameSite=Strict" in cookie and status2 not in (302, 303)
215
+ self.add("login.single-use", "PASS" if ok else "FAIL",
216
+ "first redeem HTTP %d (%s), second HTTP %d" % (status1, "cookie ok" if "HttpOnly" in cookie else "no HttpOnly cookie", status2))
217
+
218
+ # lifecycle ----------------------------------------------------------------
219
+ def lifecycle_rules(self) -> None:
220
+ life = self.d.get("lifecycle") or {}
221
+ data = fs.expand(str((self.d.get("paths") or {}).get("data", "~/")))
222
+ inside = subprocess.run(["git", "-C", str(data), "rev-parse", "--show-toplevel"], capture_output=True, text=True) if data.is_dir() and shutil.which("git") else None
223
+ self.state_rule(data, inside)
224
+ self.lock_rule(data)
225
+ if life.get("manager") != "launchd":
226
+ self.add("lifecycle.launchd", "NOT_RUN", "lifecycle.manager is %s" % life.get("manager"))
227
+ return
228
+ plist_path = fs.expand(str(life.get("plist")))
229
+ try:
230
+ plist = plistlib.loads(plist_path.read_bytes())
231
+ except (OSError, ValueError) as exc:
232
+ self.add("lifecycle.plist", "FAIL", "cannot read %s: %s" % (plist_path, exc))
233
+ return
234
+ problems = []
235
+ if plist.get("Label") != life.get("label"):
236
+ problems.append("Label %r" % plist.get("Label"))
237
+ if plist.get("RunAtLoad") is not True:
238
+ problems.append("RunAtLoad is not true")
239
+ if plist.get("KeepAlive") is not True:
240
+ problems.append("KeepAlive is %r, not true" % plist.get("KeepAlive"))
241
+ for key, value in (plist.get("EnvironmentVariables") or {}).items():
242
+ if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)$", key) and not key.endswith("_FILE"):
243
+ problems.append("secret-like variable %s in the plist" % key)
244
+ if self.token and self.token in str(value):
245
+ problems.append("the service token itself is in the plist")
246
+ self.add("lifecycle.plist", "FAIL" if problems else "PASS", "; ".join(problems) or "%s: RunAtLoad, KeepAlive, no secrets" % plist_path.name)
247
+ if not shutil.which("launchctl"):
248
+ self.add("lifecycle.one-copy", "NOT_RUN", "launchctl is not available")
249
+ return
250
+ out = subprocess.run(["launchctl", "print", "gui/%d/%s" % (os.getuid(), life.get("label"))], capture_output=True, text=True)
251
+ match = re.search(r"^\s*pid = (\d+)", out.stdout, re.MULTILINE)
252
+ served = ((self.wk or {}).get("process") or {}).get("pid")
253
+ if out.returncode != 0:
254
+ self.add("lifecycle.one-copy", "FAIL", "launchd job %s is not loaded" % life.get("label"))
255
+ elif not match:
256
+ self.add("lifecycle.one-copy", "FAIL", "launchd job loaded but not running")
257
+ else:
258
+ same = int(match.group(1)) == served
259
+ self.add("lifecycle.one-copy", "PASS" if same else "FAIL",
260
+ "launchd pid %s, answering pid %s" % (match.group(1), served))
261
+
262
+ def state_rule(self, data: Path, inside: Optional[subprocess.CompletedProcess]) -> None:
263
+ """State must not live in the service's CODE: its own checkout or a release.
264
+
265
+ A repository that exists to version the data itself (a registry, a plan) is
266
+ a store, not code; deleting the service's checkout does not touch it."""
267
+ rule = "state.outside-code"
268
+ if "/releases/" in str(data):
269
+ self.add(rule, "FAIL", "%s is inside a release directory" % data)
270
+ return
271
+ if inside is None:
272
+ self.add(rule, "NOT_RUN", "data directory %s missing or git absent" % data)
273
+ return
274
+ if inside.returncode != 0:
275
+ self.add(rule, "PASS", "%s is not inside a repository" % data)
276
+ return
277
+ top = inside.stdout.strip()
278
+ remote = subprocess.run(["git", "-C", top, "remote", "get-url", "origin"], capture_output=True, text=True)
279
+ source = str((self.d.get("source") or {}).get("repository") or "")
280
+ if not source:
281
+ self.add(rule, "NOT_RUN", "data is inside repository %s and the descriptor names no source.repository to tell code from data" % top)
282
+ elif remote.returncode == 0 and same_repository(remote.stdout.strip(), source):
283
+ self.add(rule, "FAIL", "data lives in the service's own code checkout %s (%s)" % (top, source))
284
+ else:
285
+ self.add(rule, "PASS", "data is in repository %s, which is not the service's code (%s)" % (top, remote.stdout.strip() or "no remote"))
286
+
287
+ def lock_rule(self, data: Path) -> None:
288
+ lock = data / "service.lock"
289
+ if not lock.exists():
290
+ self.add("lifecycle.instance-lock", "FAIL", "%s does not exist" % lock)
291
+ return
292
+ try:
293
+ import fcntl
294
+ except ImportError:
295
+ self.add("lifecycle.instance-lock", "NOT_RUN", "fcntl unavailable")
296
+ return
297
+ fd = os.open(str(lock), os.O_RDONLY)
298
+ try:
299
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
300
+ except OSError as exc:
301
+ held = exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES)
302
+ self.add("lifecycle.instance-lock", "PASS" if held else "FAIL", "held by the running service" if held else str(exc))
303
+ else:
304
+ fcntl.flock(fd, fcntl.LOCK_UN)
305
+ self.add("lifecycle.instance-lock", "FAIL", "%s exists but nobody holds it" % lock)
306
+ finally:
307
+ os.close(fd)
308
+
309
+ def run(self) -> List[Result]:
310
+ self.descriptor_rules()
311
+ if not self.port:
312
+ return self.results
313
+ self.well_known_rules()
314
+ if self.wk is not None:
315
+ self.network_rules()
316
+ self.auth_rules()
317
+ self.login_rules()
318
+ self.lifecycle_rules()
319
+ return self.results
320
+
321
+
322
+ def same_repository(a: str, b: str) -> bool:
323
+ """git@github.com:o/r.git, https://github.com/o/r and github.com/o/r are one repository."""
324
+ def norm(u: str) -> str:
325
+ u = u.strip().lower()
326
+ u = re.sub(r"^git@([^:]+):", r"\1/", u)
327
+ u = re.sub(r"^[a-z+]+://", "", u)
328
+ u = re.sub(r"^[^@/]+@", "", u)
329
+ return re.sub(r"\.git$", "", u).rstrip("/")
330
+ return bool(a and b) and norm(a) == norm(b)
331
+
332
+
333
+ def locate(target: Optional[str], descriptor: Optional[str], services_dir: Path) -> Path:
334
+ if descriptor:
335
+ return Path(descriptor).expanduser()
336
+ if not target:
337
+ raise SystemExit(2)
338
+ service_id, _, instance = target.partition(".")
339
+ return fs.descriptor_path(service_id, instance or "default", services_dir)
340
+
341
+
342
+ def main(argv: Optional[List[str]] = None) -> int:
343
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
344
+ parser.add_argument("target", nargs="?", help="<id> or <id>.<instance>")
345
+ parser.add_argument("--descriptor")
346
+ parser.add_argument("--services-dir")
347
+ parser.add_argument("--json", action="store_true")
348
+ parser.add_argument("--skip-login", action="store_true")
349
+ args = parser.parse_args(argv)
350
+ if not args.target and not args.descriptor:
351
+ parser.print_usage(sys.stderr)
352
+ return 2
353
+ services_dir = Path(args.services_dir).expanduser() if args.services_dir else fs.services_dir()
354
+ path = locate(args.target, args.descriptor, services_dir)
355
+ try:
356
+ descriptor = json.loads(path.read_text(encoding="utf-8"))
357
+ except (OSError, ValueError) as exc:
358
+ print("No readable descriptor at %s: %s" % (path, exc), file=sys.stderr)
359
+ return 1
360
+ results = Probe(path, descriptor, services_dir, args.skip_login).run()
361
+ failed = sum(r["verdict"] == "FAIL" for r in results)
362
+ if args.json:
363
+ print(json.dumps({"descriptor": str(path), "results": results, "failed": failed}, indent=2))
364
+ else:
365
+ width = max(len(r["rule"]) for r in results)
366
+ for r in results:
367
+ print("%-8s %-*s %s" % (r["verdict"], width, r["rule"], r["evidence"]))
368
+ print("\n%d rule(s), %d FAIL, %d NOT_RUN" % (len(results), failed, sum(r["verdict"] == "NOT_RUN" for r in results)))
369
+ return 1 if failed else 0
370
+
371
+
372
+ if __name__ == "__main__":
373
+ raise SystemExit(main())