@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.
- package/CHANGELOG.md +82 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +17 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_identifiers.py +182 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +7 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +19 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +19 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +19 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-identifiers.mjs +156 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +9 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +152 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +11 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py +182 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +11 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +204 -15
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +2 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py
ADDED
|
@@ -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)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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]]:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1293
|
-
raise ServiceError("route is subscription or
|
|
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,
|
|
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:
|
|
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(),
|
|
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)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
8
|
+
version: "0.25.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
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 `
|
|
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
|
|
@@ -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.
|
|
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
|