@passioncode-ai/fabric-agent-adapter 0.23.0 → 0.25.0

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 (25) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +17 -5
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_identifiers.py +182 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +7 -2
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +7 -4
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +19 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +19 -5
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +19 -2
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -4
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-identifiers.mjs +156 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +9 -6
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +152 -11
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +11 -4
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py +182 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +11 -6
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +204 -15
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +2 -1
  23. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  24. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  25. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
@@ -0,0 +1,182 @@
1
+ """Identifier equality (contract DEC-0076): two identifiers are the same when their normal forms are equal.
2
+
3
+ A port of the contract's `src/identifiers.ts`, held to its `fixtures/vectors/identifiers.json`. The normal form of a URI
4
+ is RFC 3986 §6.2.2 syntax-based normalization: the scheme and the host lower-cased and percent-encoding hex upper-cased
5
+ (§6.2.2.1), percent-encoded unreserved characters decoded (§6.2.2.2), dot segments removed from a hierarchical path
6
+ (§6.2.2.3), and only the leading ones from a rootless path; nothing scheme-based (§6.2.3). A `urn:` URI follows RFC 8141
7
+ §3.1 instead: the scheme and the NID lower-cased, percent-encoding hex upper-cased, nothing decoded, the NSS's case kept,
8
+ the r-, q- and f-components ignored. A string that is not a URI by RFC 3986's `URI` production is its own normal form.
9
+ Nothing here raises. Standard library only; this file is byte-identical in both skills' `scripts/`."""
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import Any, Optional, Tuple
14
+
15
+ _UNRESERVED = r"A-Za-z0-9\-._~"
16
+ _SUB_DELIMS = r"!$&'()*+,;="
17
+ _PCT = r"%[0-9A-Fa-f]{2}"
18
+
19
+
20
+ def _run(chars: str) -> "re.Pattern[str]":
21
+ """`*( <chars> / pct-encoded )`, linear: one character class beside one fixed triplet."""
22
+ return re.compile(r"(?:[%s]|%s)*" % (chars, _PCT), re.ASCII)
23
+
24
+
25
+ _USERINFO = _run(_UNRESERVED + _SUB_DELIMS + ":")
26
+ _REG_NAME = _run(_UNRESERVED + _SUB_DELIMS)
27
+ _PATH = _run(_UNRESERVED + _SUB_DELIMS + ":@/")
28
+ _QUERY = _run(_UNRESERVED + _SUB_DELIMS + ":@/?")
29
+ _PORT = re.compile(r"[0-9]*", re.ASCII)
30
+ _SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*", re.ASCII)
31
+
32
+ # RFC 3986 §3.2.2: IP-literal = "[" ( IPv6address / IPvFuture ) "]".
33
+ _H16 = "[0-9A-Fa-f]{1,4}"
34
+ _DEC_OCTET = "(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])"
35
+ _IPV4 = r"%s(?:\.%s){3}" % (_DEC_OCTET, _DEC_OCTET)
36
+ _LS32 = "(?:%s:%s|%s)" % (_H16, _H16, _IPV4)
37
+ _IPV6 = "|".join([
38
+ "(?:%s:){6}%s" % (_H16, _LS32),
39
+ "::(?:%s:){5}%s" % (_H16, _LS32),
40
+ "(?:%s)?::(?:%s:){4}%s" % (_H16, _H16, _LS32),
41
+ "(?:(?:%s:){0,1}%s)?::(?:%s:){3}%s" % (_H16, _H16, _H16, _LS32),
42
+ "(?:(?:%s:){0,2}%s)?::(?:%s:){2}%s" % (_H16, _H16, _H16, _LS32),
43
+ "(?:(?:%s:){0,3}%s)?::%s:%s" % (_H16, _H16, _H16, _LS32),
44
+ "(?:(?:%s:){0,4}%s)?::%s" % (_H16, _H16, _LS32),
45
+ "(?:(?:%s:){0,5}%s)?::%s" % (_H16, _H16, _H16),
46
+ "(?:(?:%s:){0,6}%s)?::" % (_H16, _H16),
47
+ ])
48
+ _IP_LITERAL = re.compile(r"\[(?:%s|[Vv][0-9A-Fa-f]+\.[%s%s:]+)\]" % (_IPV6, _UNRESERVED, _SUB_DELIMS), re.ASCII)
49
+
50
+ # RFC 8141 §2: NID = (alphanum) 0*30(ldh) (alphanum), then ":" and a non-empty NSS.
51
+ _URN_NAME = re.compile(r"([A-Za-z0-9][A-Za-z0-9-]{0,30}[A-Za-z0-9]):(.+)", re.ASCII | re.DOTALL)
52
+ _SPLIT = re.compile(r"([^:/?#]+):(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?", re.DOTALL)
53
+ _TRIPLET = re.compile(r"%([0-9A-Fa-f]{2})")
54
+ _UNRESERVED_CHAR = re.compile(r"[A-Za-z0-9\-._~]", re.ASCII)
55
+
56
+
57
+ def _percent(text: str, keep_encoded: bool = False) -> str:
58
+ """Percent-encoding hex upper-cased (§6.2.2.1), and unreserved characters decoded unless ``keep_encoded``."""
59
+ def one(match: "re.Match[str]") -> str:
60
+ char = chr(int(match.group(1), 16))
61
+ return char if not keep_encoded and _UNRESERVED_CHAR.fullmatch(char) else "%" + match.group(1).upper()
62
+ return _TRIPLET.sub(one, text)
63
+
64
+
65
+ def _strip_leading_dots(path: str) -> str:
66
+ """A rootless path loses its leading "./" and "../" segments while what is left stays rootless (DEC-0076)."""
67
+ start = 0
68
+ while path.startswith("./", start) or path.startswith("../", start):
69
+ start += 2 if path.startswith("./", start) else 3
70
+ rest = path[start:]
71
+ return path if rest == "" or rest.startswith("/") else rest
72
+
73
+
74
+ def _remove_dot_segments(path: str) -> str:
75
+ """RFC 3986 §5.2.4 remove_dot_segments over a path whose "." and ".." are already decoded."""
76
+ absolute = path.startswith("/")
77
+ segments = (path[1:] if absolute else path).split("/")
78
+ output = []
79
+ for index, segment in enumerate(segments):
80
+ last = index == len(segments) - 1
81
+ if segment in (".", ".."):
82
+ if segment == ".." and output:
83
+ output.pop()
84
+ if last: # a dot segment that ends the path leaves it ending in "/"
85
+ output.append("")
86
+ continue
87
+ output.append(segment)
88
+ return ("/" if absolute else "") + "/".join(output)
89
+
90
+
91
+ def _authority_parts(authority: str) -> Optional[Tuple[Optional[str], str, Optional[str]]]:
92
+ at = authority.rfind("@")
93
+ userinfo = None if at == -1 else authority[:at]
94
+ host_port = authority if at == -1 else authority[at + 1:]
95
+ bracket = host_port.find("]") if host_port.startswith("[") else -1
96
+ colon = host_port.find(":", 0 if bracket == -1 else bracket)
97
+ host = host_port if colon == -1 else host_port[:colon]
98
+ port = None if colon == -1 else host_port[colon + 1:]
99
+ if userinfo is not None and not _USERINFO.fullmatch(userinfo):
100
+ return None
101
+ if port is not None and not _PORT.fullmatch(port):
102
+ return None
103
+ if not (_IP_LITERAL.fullmatch(host) if host.startswith("[") else _REG_NAME.fullmatch(host)):
104
+ return None
105
+ return userinfo, host, port
106
+
107
+
108
+ def _parse(text: str) -> Optional[Tuple[str, Optional[str], str, Optional[str], Optional[str]]]:
109
+ """RFC 3986 Appendix B's split, then each component checked against its §3 production; None when one fails."""
110
+ match = _SPLIT.fullmatch(text)
111
+ if not match:
112
+ return None
113
+ scheme, authority, path, query, fragment = match.groups()
114
+ path = path or ""
115
+ if not _SCHEME.fullmatch(scheme) or not _PATH.fullmatch(path):
116
+ return None
117
+ if query is not None and not _QUERY.fullmatch(query):
118
+ return None
119
+ if fragment is not None and not _QUERY.fullmatch(fragment):
120
+ return None
121
+ if authority is not None and _authority_parts(authority) is None:
122
+ return None
123
+ if authority is None and path == "":
124
+ return None
125
+ return scheme, authority, path, query, fragment
126
+
127
+
128
+ def _urn_form(authority: Optional[str], path: str, query: Optional[str]) -> str:
129
+ name = _URN_NAME.fullmatch(path)
130
+ path = "%s:%s" % (name.group(1).lower(), _percent(name.group(2), True)) if name else _percent(path, True)
131
+ # A "?" not followed by "+" or "=" is no URN component (RFC 8141 §2): it is kept, never dropped unread.
132
+ kept = "?" + _percent(query, True) if query is not None and not re.match(r"[+=]", query) else ""
133
+ auth = "//" + _percent(authority, True) if authority is not None else ""
134
+ return "urn:%s%s%s" % (auth, path, kept)
135
+
136
+
137
+ def _generic_form(scheme: str, authority: Optional[str], path: str, query: Optional[str], fragment: Optional[str]) -> str:
138
+ auth = ""
139
+ if authority is not None:
140
+ userinfo, host, port = _authority_parts(authority) # type: ignore[misc]
141
+ # The host is case-insensitive (§3.2.2); its remaining percent triplets keep upper-case hex.
142
+ lowered = re.sub(r"%[0-9a-f]{2}", lambda m: m.group(0).upper(), _percent(host).lower())
143
+ auth = "//%s%s%s" % ("" if userinfo is None else _percent(userinfo) + "@", lowered, "" if port is None else ":" + port)
144
+ decoded = _percent(path)
145
+ hierarchical = authority is not None or decoded.startswith("/")
146
+ out = _remove_dot_segments(decoded) if hierarchical else _strip_leading_dots(decoded)
147
+ if authority is None and out.startswith("//"): # it would read as an authority: keep the decoded path
148
+ out = decoded
149
+ q = "" if query is None else "?" + _percent(query)
150
+ f = "" if fragment is None else "#" + _percent(fragment)
151
+ return "%s:%s%s%s%s" % (scheme.lower(), auth, out, q, f)
152
+
153
+
154
+ def normal_identifier(value: Any) -> Optional[str]:
155
+ """The normal form of an identifier: a URI's syntax-normalized form (RFC 3986 §6.2.2; RFC 8141 §3.1 for `urn:`),
156
+ any other string as it is, None for a value that is not a string. The contract's `normalIdentifier`."""
157
+ if not isinstance(value, str):
158
+ return None
159
+ parts = _parse(value)
160
+ if parts is None:
161
+ return value
162
+ scheme, authority, path, query, fragment = parts
163
+ return _urn_form(authority, path, query) if scheme.lower() == "urn" else _generic_form(scheme, authority, path, query, fragment)
164
+
165
+
166
+ def _strict_equal(a: Any, b: Any) -> bool:
167
+ """JavaScript's === on parsed JSON: numbers by value, booleans only with booleans, objects by identity."""
168
+ if isinstance(a, bool) or isinstance(b, bool):
169
+ return isinstance(a, bool) and isinstance(b, bool) and a == b
170
+ if isinstance(a, (int, float)) and isinstance(b, (int, float)):
171
+ return a == b
172
+ if isinstance(a, (dict, list)) or isinstance(b, (dict, list)):
173
+ return a is b
174
+ return type(a) is type(b) and a == b
175
+
176
+
177
+ def same_identifier(a: Any, b: Any) -> bool:
178
+ """Two identifiers are the same when their normal forms are equal (DEC-0076). A value that is not a string is no
179
+ identifier and is compared as it is (JavaScript's ===). The contract's `sameIdentifier`."""
180
+ if not isinstance(a, str) or not isinstance(b, str):
181
+ return _strict_equal(a, b)
182
+ return normal_identifier(a) == normal_identifier(b)
@@ -19,6 +19,7 @@ import shutil
19
19
  import subprocess
20
20
  from typing import Any, Callable, Dict, List, Optional, Sequence
21
21
 
22
+ import fabric_identifiers as _ids
22
23
  import fabric_secrets as _secrets
23
24
  import fabric_service as _fs
24
25
 
@@ -314,9 +315,12 @@ def _object_at(value: Any, key: str) -> Dict[str, Any]:
314
315
 
315
316
 
316
317
  def _ref_key(ref: Any) -> str:
318
+ """A revision reference compared whole: id, revision and content hash. The id is an identifier, so it enters the key
319
+ in its normal form (DEC-0076)."""
317
320
  if not isinstance(ref, dict):
318
321
  return ""
319
- return "%s@%s#%s" % (_field(ref, "id"), _field(ref, "revision"), _field(ref, "contentHash"))
322
+ normal = _ids.normal_identifier(ref.get("id"))
323
+ return "%s@%s#%s" % (normal if normal is not None else _field(ref, "id"), _field(ref, "revision"), _field(ref, "contentHash"))
320
324
 
321
325
 
322
326
  def _same_ref(a: Any, b: Any) -> bool:
@@ -343,7 +347,7 @@ def _runner_route(value: Dict[str, Any]) -> List[Dict[str, str]]:
343
347
  def _route_bundle(value: Dict[str, Any]) -> List[Dict[str, str]]:
344
348
  route = _object_at(value, "route")
345
349
  admitted = {_ref_key(a.get("provider")) for a in _list(value.get("admissions"))
346
- if a.get("state") == "admitted" and a.get("capability", _MISSING) == route.get("capability", _MISSING)}
350
+ if a.get("state") == "admitted" and _ids.same_identifier(a.get("capability", _MISSING), route.get("capability", _MISSING))}
347
351
  findings = []
348
352
  for index, candidate in enumerate(_candidates(route)):
349
353
  key = _ref_key(candidate.get("provider"))
@@ -363,9 +367,10 @@ def _binding_agreement(binding: Dict[str, Any], route: Dict[str, Any]) -> List[D
363
367
  def disagree(path: str, message: str) -> None:
364
368
  findings.append(_finding("FAC-SEM-032", "/binding/" + path, message))
365
369
 
366
- if binding.get("project", _MISSING) != route.get("project", _MISSING):
370
+ # DEC-0076: project and capability are identifiers, compared by normal form.
371
+ if not _ids.same_identifier(binding.get("project", _MISSING), route.get("project", _MISSING)):
367
372
  disagree("project", "the binding is for %s, the route for %s" % (_field(binding, "project"), _field(route, "project")))
368
- if binding.get("capability", _MISSING) != route.get("capability", _MISSING):
373
+ if not _ids.same_identifier(binding.get("capability", _MISSING), route.get("capability", _MISSING)):
369
374
  disagree("capability", "the binding binds %s, the route orders %s" % (_field(binding, "capability"), _field(route, "capability")))
370
375
  if binding.get("profileKind") != "local-runner":
371
376
  disagree("profileKind", "a runner route serves the local-runner profile, not %s" % _field(binding, "profileKind"))
@@ -408,9 +413,9 @@ def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
408
413
 
409
414
  if not _same_ref(event.get("route"), route.get("meta")):
410
415
  wrong("/route", "the event names route %s, not %s" % (_ref_key(event.get("route")), _ref_key(route.get("meta"))))
411
- if event.get("project", _MISSING) != route.get("project", _MISSING):
416
+ if not _ids.same_identifier(event.get("project", _MISSING), route.get("project", _MISSING)):
412
417
  wrong("/project", "the event is for %s, the route for %s" % (_field(event, "project"), _field(route, "project")))
413
- if event.get("capability", _MISSING) != route.get("capability", _MISSING):
418
+ if not _ids.same_identifier(event.get("capability", _MISSING), route.get("capability", _MISSING)):
414
419
  wrong("/capability", "the event names %s, the route orders %s" % (_field(event, "capability"), _field(route, "capability")))
415
420
 
416
421
  def candidate_at(ref: Dict[str, Any], path: str) -> Optional[Dict[str, Any]]:
@@ -90,7 +90,11 @@ _SPAN_ID = re.compile(r"^(?!0{16}$)[0-9a-f]{16}$")
90
90
  # DEC-0057: `//server/…` and `/\\server…` are shares on Windows too, so a leading slash is followed by neither.
91
91
  _LOCAL_PATH = re.compile(r"^(~[\\/]|/(?![\\/])|[A-Za-z]:[\\/])")
92
92
  # DEC-0032: the supervisor per system and the field that names its job.
93
- MANAGERS = ("launchd", "systemd", "task-scheduler", "none")
93
+ # DEC-0061: `node` (Project Observatory's node supervises its children, no supervisor file); DEC-0069: `person` (an app
94
+ # a person installs and starts; its descriptor says it is installed, never that it runs).
95
+ MANAGERS = ("launchd", "systemd", "task-scheduler", "node", "person", "none")
96
+ #: Managers that write no launchd label or plist (DEC-0057, DEC-0061, DEC-0069).
97
+ _NOT_LAUNCHD = ("systemd", "task-scheduler", "node", "person")
94
98
  _AUTH_HEADER = re.compile(r"[A-Za-z][A-Za-z0-9-]{0,63}")
95
99
  _UNIT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$")
96
100
  _TASK = re.compile(r"^\\[A-Za-z0-9 ._\\-]{1,254}$")
@@ -886,7 +890,10 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
886
890
  remote = placement_of(descriptor) == "remote"
887
891
  if "placement" in descriptor and descriptor["placement"] not in PLACEMENTS:
888
892
  problems.append("placement must be local or remote")
889
- required = ("protocol", "id", "instance", "name", "origin", "auth", "lifecycle", "installedAt", "installedBy")
893
+ life_in = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
894
+ app = life_in.get("manager") == "person"
895
+ # DEC-0069: an app's descriptor says it is installed, never that it runs; its live origin is in its own liveness file.
896
+ required = ("protocol", "id", "instance", "name", "auth", "lifecycle", "installedAt", "installedBy") + (() if app else ("origin",))
890
897
  if not remote:
891
898
  required = required + ("paths",)
892
899
  for key in required:
@@ -902,7 +909,7 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
902
909
  problems.append("instance must match %s" % _INSTANCE.pattern)
903
910
  if remote:
904
911
  problems.extend(remote_origin_problems(descriptor["origin"]))
905
- elif not _ORIGIN.match(str(descriptor["origin"])):
912
+ elif "origin" in descriptor and not _ORIGIN.match(str(descriptor["origin"])):
906
913
  problems.append("origin must be http://127.0.0.1:<port>")
907
914
  auth = descriptor.get("auth") or {}
908
915
  if "tokenFile" not in auth:
@@ -924,14 +931,21 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
924
931
  life = descriptor.get("lifecycle") or {}
925
932
  manager = life.get("manager")
926
933
  if manager not in MANAGERS:
927
- problems.append("lifecycle.manager must be launchd, systemd, task-scheduler or none")
934
+ problems.append("lifecycle.manager must be launchd, systemd, task-scheduler, node, person or none")
935
+ # DEC-0069: `app` is the installed app of a person's descriptor, and nothing else's.
936
+ if manager == "person" and "app" not in life:
937
+ problems.append("a person's app declares lifecycle.app, the installed app")
938
+ if "app" in life and manager != "person":
939
+ problems.append("lifecycle.app belongs to manager person only")
940
+ if "app" in life and not _LOCAL_PATH.match(str(life["app"])):
941
+ problems.append("lifecycle.app must be a local path")
928
942
  if manager == "launchd" and not (life.get("label") and str(life.get("plist", "")).endswith(".plist")):
929
943
  problems.append("a launchd service declares label and plist")
930
944
  if "plist" in life and not _LOCAL_PATH.match(str(life["plist"])):
931
945
  problems.append("lifecycle.plist must be a local path")
932
- # DEC-0057: a descriptor names one supervisor; launchd's label and plist never sit beside another.
946
+ # DEC-0057, DEC-0061, DEC-0069: a descriptor names one supervisor; launchd's label and plist never sit beside another.
933
947
  for field in ("label", "plist"):
934
- if field in life and manager in ("systemd", "task-scheduler"):
948
+ if field in life and manager in _NOT_LAUNCHD:
935
949
  problems.append("lifecycle.%s belongs to launchd, not to %s" % (field, manager))
936
950
  # DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
937
951
  for field, owner, pattern in (("unit", "systemd", _UNIT), ("task", "task-scheduler", _TASK)):
@@ -949,6 +963,7 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
949
963
  problems.append("a remote service has no %s %s" % (of, field))
950
964
  if "update" in (descriptor.get("commands") or {}):
951
965
  problems.append("a remote service declares no update command")
966
+ problems.extend(descriptor_member_problems(descriptor))
952
967
  for name, argv in (descriptor.get("commands") or {}).items():
953
968
  if name not in ("doctor", "update"):
954
969
  problems.append("unknown command %s" % name)
@@ -959,6 +974,166 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
959
974
  return problems
960
975
 
961
976
 
977
+ # --- DEC-0061 and DEC-0069: who installed it, how its model calls sign in, offered to others, an app's own descriptor ---
978
+
979
+ #: The organization edition's data classes, least sensitive first (DEC-0061, DEC-0053).
980
+ PEER_DATA_CLASSES = ("public", "internal", "confidential", "restricted")
981
+ #: The kind of a runner's sign-in, never a credential's value (DEC-0061).
982
+ CREDENTIAL_KINDS = ("subscription", "key", "platform-metered", "none")
983
+ #: A subscription runs only in its vendor's runner (DEC-0046): runner kind → login.
984
+ SUBSCRIPTION_RUNNERS = {"claude-code": "claude", "kimi-code": "kimi", "codex": "chatgpt"}
985
+ _PEER_ENDPOINT = re.compile(r"^https://(?P<host>([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?$", re.ASCII)
986
+ _RESIDENCY = re.compile(r"^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])$", re.ASCII)
987
+ _RUNNER_KIND_NAME = re.compile(r"^[a-z][a-z0-9-]{1,62}$", re.ASCII)
988
+
989
+
990
+ def _pointer(name: str) -> str:
991
+ """A JSON Pointer token (RFC 6901 §4)."""
992
+ return str(name).replace("~", "~0").replace("/", "~1")
993
+
994
+
995
+ def _text(value: Any, low: int = 1, high: int = 200) -> bool:
996
+ return isinstance(value, str) and low <= len(value) <= high
997
+
998
+
999
+ def descriptor_member_problems(descriptor: Dict[str, Any]) -> List[str]:
1000
+ """DEC-0061 and DEC-0069 on a descriptor's `installedBy`, `runner` and `peer`, as the schema shapes them, with
1001
+ FAC-SEM-085 (a peer on a reserved name) and FAC-SEM-086 (a credential-shaped value in `runner` or `installedBy`,
1002
+ named by its place, never its value)."""
1003
+ import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
1004
+ problems: List[str] = []
1005
+ life = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
1006
+ by = descriptor.get("installedBy")
1007
+ places: List[Tuple[str, Any]] = []
1008
+ if isinstance(by, str):
1009
+ if not _text(by):
1010
+ problems.append("installedBy is the installer and its version, 1 to 200 characters")
1011
+ places.append(("/installedBy", by))
1012
+ elif isinstance(by, dict):
1013
+ problems += ["installedBy has no member %s" % k for k in by if k not in ("by", "role", "tool")]
1014
+ if by.get("by") not in ("person", "node", "organization"):
1015
+ problems.append("installedBy.by is person, node or organization")
1016
+ if by.get("by") == "person" and by.get("role") not in ("operator", "administrator"):
1017
+ problems.append("installedBy.role is operator or administrator for a person")
1018
+ if by.get("by") != "person" and "role" in by:
1019
+ problems.append("installedBy.role belongs to a person only")
1020
+ if not _text(by.get("tool")):
1021
+ problems.append("installedBy.tool is the installer and its version, 1 to 200 characters")
1022
+ places += [("/installedBy/%s" % _pointer(k), v) for k, v in by.items()]
1023
+ else:
1024
+ problems.append("installedBy is the installer's name or {by, role?, tool}")
1025
+ if life.get("manager") == "person" and not (isinstance(by, dict) and by.get("by") == "person"):
1026
+ problems.append("an app's descriptor names who installed it as a person: installedBy {by: person, role, tool} (DEC-0069)")
1027
+ runner = descriptor.get("runner")
1028
+ if "runner" in descriptor:
1029
+ if not isinstance(runner, dict):
1030
+ problems.append("runner is {kind?, credentialKind}")
1031
+ runner = {}
1032
+ problems += ["runner has no member %s" % k for k in runner if k not in ("kind", "credentialKind")]
1033
+ if runner.get("credentialKind") not in CREDENTIAL_KINDS:
1034
+ problems.append("runner.credentialKind is subscription, key, platform-metered or none")
1035
+ if "kind" in runner and not (isinstance(runner["kind"], str) and _RUNNER_KIND_NAME.match(runner["kind"])):
1036
+ problems.append("runner.kind is a runner kind's name")
1037
+ if runner.get("credentialKind") == "subscription" and runner.get("kind") not in SUBSCRIPTION_RUNNERS:
1038
+ problems.append("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex")
1039
+ places += [("/runner/%s" % _pointer(k), v) for k, v in runner.items()]
1040
+ if "peer" in descriptor:
1041
+ peer = descriptor.get("peer")
1042
+ if not isinstance(peer, dict):
1043
+ problems.append("peer is {endpoint, custody, residency, maxDataClass}")
1044
+ peer = {}
1045
+ problems += ["peer has no member %s" % k for k in peer if k not in ("endpoint", "custody", "residency", "maxDataClass")]
1046
+ endpoint = peer.get("endpoint")
1047
+ match = _PEER_ENDPOINT.match(endpoint) if isinstance(endpoint, str) and len(endpoint) <= 2048 else None
1048
+ if not match:
1049
+ problems.append("peer.endpoint is https://<dns-name>[:<port>][/<path>], no query, fragment, userinfo or IP literal")
1050
+ elif _RESERVED_HOST.search(match.group("host")):
1051
+ problems.append("FAC-SEM-085: a service offered to others cannot be reached at the reserved name %s" % match.group("host"))
1052
+ if peer.get("custody") not in ("organization-managed", "personal"):
1053
+ problems.append("peer.custody is organization-managed or personal")
1054
+ residency = peer.get("residency")
1055
+ if (not isinstance(residency, list) or not 1 <= len(residency) <= 32 or len(set(map(json.dumps, residency))) != len(residency)
1056
+ or not all(isinstance(r, str) and _RESIDENCY.match(r) for r in residency)):
1057
+ problems.append("peer.residency is 1 to 32 distinct ISO 3166-1 alpha-2 codes in upper case, EU admitted, no user-assigned code")
1058
+ if peer.get("maxDataClass") not in PEER_DATA_CLASSES:
1059
+ problems.append("peer.maxDataClass is public, internal, confidential or restricted")
1060
+ if "runner" not in descriptor:
1061
+ problems.append("a service offered to others declares runner (DEC-0061)")
1062
+ elif isinstance(runner, dict) and runner.get("credentialKind") == "subscription":
1063
+ problems.append("a service offered to others never runs on a subscription (DEC-0061)")
1064
+ for place, value in places:
1065
+ if isinstance(value, str) and fabric_secrets.secret_argument(value):
1066
+ problems.append("FAC-SEM-086: %s holds a value shaped like a credential; it names a kind, a role or an installer" % place)
1067
+ return problems
1068
+
1069
+
1070
+ def peer_refusal(descriptor: Any, data_class: Any) -> Optional[str]:
1071
+ """The host's check before it routes a request from anyone but the service's operator (DEC-0061, FAC-SEM-088):
1072
+ `not-offered` (no `peer`), `peer-invalid` (its maximum is not one of the four classes), `data-class-unknown` (the
1073
+ request's class is not one of them, exactly as written) or `above-max-data-class`; None when it may. The contract's
1074
+ `peerRefusal`."""
1075
+ if not isinstance(descriptor, dict) or not isinstance(descriptor.get("peer"), dict):
1076
+ return "not-offered"
1077
+ ceiling = descriptor["peer"].get("maxDataClass")
1078
+ if not isinstance(ceiling, str) or ceiling not in PEER_DATA_CLASSES:
1079
+ return "peer-invalid"
1080
+ if not isinstance(data_class, str) or data_class not in PEER_DATA_CLASSES:
1081
+ return "data-class-unknown"
1082
+ return "above-max-data-class" if PEER_DATA_CLASSES.index(data_class) > PEER_DATA_CLASSES.index(ceiling) else None
1083
+
1084
+
1085
+ def app_descriptor_action(existing: Any, tool: str, app_path: str) -> str:
1086
+ """What an app that writes its own descriptor does with it on a start (DEC-0069): `keep` when it runs from a
1087
+ translocated copy (macOS App Translocation); otherwise `write` when there is none, when it is not an app's descriptor,
1088
+ when its version (`installedBy.tool`) or its path (`lifecycle.app`) differs; `keep` otherwise. The contract's
1089
+ `appDescriptorAction`."""
1090
+ if "/AppTranslocation/" in app_path:
1091
+ return "keep"
1092
+ if not isinstance(existing, dict) or not isinstance(existing.get("installedBy"), dict):
1093
+ return "write"
1094
+ recorded = existing["lifecycle"].get("app", _MISSING_APP) if isinstance(existing.get("lifecycle"), dict) else _MISSING_APP
1095
+ return "keep" if existing["installedBy"].get("tool", _MISSING_APP) == tool and recorded == app_path else "write"
1096
+
1097
+
1098
+ _MISSING_APP = object()
1099
+
1100
+
1101
+ def app_descriptor_stale(descriptor: Any, app_present: bool) -> bool:
1102
+ """Whether a reader treats a descriptor as stale (DEC-0069): an app's descriptor whose installed app it did not find.
1103
+ A supervised service's descriptor is never stale by this test. The contract's `appDescriptorStale`."""
1104
+ life = descriptor.get("lifecycle") if isinstance(descriptor, dict) else None
1105
+ return isinstance(life, dict) and life.get("manager") == "person" and not app_present
1106
+
1107
+
1108
+ def app_present(descriptor: Dict[str, Any]) -> bool:
1109
+ """Whether `lifecycle.app` of an app's descriptor exists on this machine (a bundle is a folder, an executable a file)."""
1110
+ life = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
1111
+ app = life.get("app")
1112
+ try:
1113
+ return isinstance(app, str) and expand(app).exists()
1114
+ except ServiceError: # not a local path: nothing installed there
1115
+ return False
1116
+
1117
+
1118
+ #: Fabric's own descriptor (Fabric ADR-0139): `services/fabric.default.json`.
1119
+ FABRIC_SERVICE = ("fabric", "default")
1120
+
1121
+
1122
+ def fabric_button_presence(directory: Optional[Path] = None) -> str:
1123
+ """`present` or `absent` for Fabric's button (DEC-0040 FB-5, DEC-0069, Fabric SCN-142): present when Fabric's own
1124
+ descriptor is registered and, for an app's descriptor, its app is still installed. A server sets
1125
+ `data-fabric="absent"` on the button's script otherwise. The button only follows a link, so the liveness file is
1126
+ not read here; a caller that sends anything reads it."""
1127
+ path = descriptor_path(*FABRIC_SERVICE, directory=directory)
1128
+ try:
1129
+ found = json.loads(path.read_text(encoding="utf-8"))
1130
+ except (OSError, ValueError):
1131
+ return "absent"
1132
+ if not isinstance(found, dict) or validate_descriptor(found):
1133
+ return "absent"
1134
+ return "absent" if app_descriptor_stale(found, app_present(found)) else "present"
1135
+
1136
+
962
1137
  def placement_of(descriptor: Dict[str, Any]) -> str:
963
1138
  """DEC-0019: ``local`` unless the descriptor says ``remote``."""
964
1139
  return "remote" if (descriptor or {}).get("placement") == "remote" else "local"
@@ -990,11 +1165,11 @@ def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = Non
990
1165
  raise ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
991
1166
  root = directory or services_dir()
992
1167
  me = "%s.%s" % (descriptor["id"], descriptor["instance"])
993
- # DEC-0019: only a local placement claims a port on this computer.
994
- port = None if placement_of(descriptor) == "remote" else port_of(descriptor["origin"])
1168
+ # DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
1169
+ port = None if placement_of(descriptor) == "remote" or "origin" not in descriptor else port_of(descriptor["origin"])
995
1170
  for path, other in read_descriptors(root):
996
1171
  key = "%s.%s" % (other.get("id"), other.get("instance", "default"))
997
- if key == me or port is None: # a remote origin's port is another computer's
1172
+ if key == me or port is None: # a remote origin's port is another computer's; an app claims none
998
1173
  continue
999
1174
  try:
1000
1175
  other_port = port_of(str(other.get("origin", "")))
@@ -1096,6 +1271,8 @@ _PROFILE_ID = re.compile(r"^[a-z][a-z0-9-]{0,62}$")
1096
1271
  _RUNNER_KIND = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
1097
1272
  _MODEL_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._:-]{0,127}$")
1098
1273
  ROUTES = ("subscription", "key")
1274
+ #: A usage row's route (DEC-0046, DEC-0061): beside a configuration's two, the hosting platform's own metered models.
1275
+ USAGE_ROUTES = ROUTES + ("platform-metered",)
1099
1276
 
1100
1277
 
1101
1278
  def configuration_problems(doc: Any) -> List[str]:
@@ -1289,12 +1466,12 @@ def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_t
1289
1466
  basis = cost_basis or ("unknown" if cost_usd is None else "provider")
1290
1467
  if basis not in COST_BASES or (cost_usd is None) != (basis == "unknown"):
1291
1468
  raise ServiceError("cost_basis is provider or price-list with a cost, unknown without one.")
1292
- if route is not None and route not in ROUTES:
1293
- raise ServiceError("route is subscription or key (DEC-0046).")
1469
+ if route is not None and route not in USAGE_ROUTES:
1470
+ raise ServiceError("route is subscription, key or platform-metered (DEC-0046, DEC-0061).")
1294
1471
  receipt = {"at": at or now_iso(), "provider": provider, "model": model, **counts,
1295
1472
  "costUsd": None if cost_usd is None else float(cost_usd), "costBasis": basis}
1296
1473
  if route is not None:
1297
- receipt["route"] = route # the operator's own login in its vendor's runner, or an issued key
1474
+ receipt["route"] = route # the operator's own login in its vendor's runner, an issued key, or the platform's metered models
1298
1475
  return receipt
1299
1476
 
1300
1477
 
@@ -2346,18 +2523,30 @@ def remote_token_file(service_id: str, instance: str = "default", directory: Opt
2346
2523
 
2347
2524
  def register_remote(*, service_id: str, name: str, origin: str, token: str, instance: str = "default",
2348
2525
  summary: Optional[str] = None, doctor: Optional[List[str]] = None,
2349
- directory: Optional[Path] = None, installed_by: str = "fabric-service register-remote") -> Path:
2526
+ directory: Optional[Path] = None, installed_by: Any = None,
2527
+ runner: Optional[Dict[str, str]] = None, peer: Optional[Dict[str, Any]] = None) -> Path:
2350
2528
  """DEC-0019, installer side: register an online service on THIS computer — the token file
2351
- (0600, never printed) and the descriptor. The same token is set on the platform as a secret."""
2529
+ (0600, never printed) and the descriptor. The same token is set on the platform as a secret.
2530
+
2531
+ DEC-0061: ``installed_by`` defaults to ``{"by": "person", "role": "operator", "tool": "fabric-service
2532
+ register-remote"}`` (a person runs this); pass ``role: "administrator"`` for an organization's administrator, or a
2533
+ string as before. ``runner`` (``{kind?, credentialKind}``) says how its model calls sign in, as a kind and never a
2534
+ credential; ``peer`` (``{endpoint, custody, residency, maxDataClass}``) offers it to others and needs ``runner``."""
2352
2535
  if not token or len(token.strip()) < 16:
2353
2536
  raise ServiceError("the service token must be at least 16 characters.")
2354
2537
  root = directory or services_dir()
2355
2538
  token_file = remote_token_file(service_id, instance, root)
2356
2539
  descriptor: Dict[str, Any] = {"protocol": PROTOCOL, "id": service_id, "instance": instance, "name": name,
2357
2540
  "placement": "remote", "origin": origin, "auth": {"tokenFile": str(token_file)},
2358
- "lifecycle": {"manager": "none"}, "installedAt": now_iso(), "installedBy": installed_by}
2541
+ "lifecycle": {"manager": "none"}, "installedAt": now_iso(),
2542
+ "installedBy": installed_by if installed_by is not None else
2543
+ {"by": "person", "role": "operator", "tool": "fabric-service register-remote"}}
2359
2544
  if summary:
2360
2545
  descriptor["summary"] = summary
2546
+ if runner is not None:
2547
+ descriptor["runner"] = dict(runner)
2548
+ if peer is not None:
2549
+ descriptor["peer"] = dict(peer)
2361
2550
  if doctor:
2362
2551
  descriptor["commands"] = {"doctor": list(doctor)}
2363
2552
  problems = validate_descriptor(descriptor)
@@ -350,7 +350,8 @@ def register(args: argparse.Namespace) -> int:
350
350
  else {"manager": "none"},
351
351
  "paths": {"data": str(data), "logs": []},
352
352
  "fabricManifest": str(manifest),
353
- "installedAt": fs.now_iso(), "installedBy": "sample_service.py register",
353
+ # DEC-0061: who installed it, by role and never by name — a person runs this register command.
354
+ "installedAt": fs.now_iso(), "installedBy": {"by": "person", "role": "operator", "tool": "sample_service.py register %s" % VERSION},
354
355
  }
355
356
  try:
356
357
  path = fs.write_descriptor(descriptor, Path(args.services_dir) if args.services_dir else None)
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.23.0"
8
+ version: "0.25.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0"
10
+ contract-commit: "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
11
11
  ---
12
12
 
13
13
  # Creating Fabric-compatible agents
@@ -123,7 +123,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
123
123
  The effect class is declared, never defaulted: ask the author what the agent does outside itself when the request
124
124
  does not say. Cost presets are only for a run whose cost varies; their amounts come from the author or measured runs.
125
125
 
126
- Pin exactly contract `0.1.0` at commit `fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0` and
126
+ Pin exactly contract `0.1.0` at commit `a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b` and
127
127
  read the pinned guide before implementing protocol details. If this skill is installed
128
128
  without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
129
129
  contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check
@@ -18,7 +18,7 @@ compatibility: >-
18
18
  this plugin; Codex and Kimi Code get the session-start preamble hook on the person's consent.
19
19
  metadata:
20
20
  author: PassionCode.ai
21
- version: "0.23.0"
21
+ version: "0.25.0"
22
22
  ---
23
23
 
24
24
  # Working with Fabric
@@ -60,7 +60,7 @@ WATCH_MAX_BACKOFF_S = 900
60
60
  SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
61
61
  LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
62
62
  LOCAL_ORIGIN = re.compile(r'"origin": "http://(?:127\.0\.0\.1|localhost)')
63
- VERSION = "0.23.0"
63
+ VERSION = "0.25.0"
64
64
  HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code"}
65
65
  # A session Fabric launched. FABRIC_LAUNCHED_SESSION=1 is the marker Fabric is asked to set (need D6); the others are
66
66
  # what Fabric already gives a launched session (fabric sessionBundle.ts): only their NAMES are looked at, never a value