@passioncode-ai/fabric-agent-adapter 0.14.0 → 0.16.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 +75 -0
- package/README.md +2 -2
- 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 +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/demo.html +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +280 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +30 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +31 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +64 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +196 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +86 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +41 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +277 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +132 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +54 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Runner and model profiles in the kit (Fabric Agent Contract DEC-0046, Fabric ADR-0147). A port of the contract's
|
|
3
|
+
`src/profile-rules.ts` at the pin — `resolve_model_profile` and the FAC-SEM-058…060 checks — held to the contract's own
|
|
4
|
+
answers by test/fixtures/contract/profile-vectors.json; plus what a service does with them: the doctor's runner and
|
|
5
|
+
model checks (runners.md C2) and the well-known `configuration` with its summary tiles ("Runner & model").
|
|
6
|
+
|
|
7
|
+
import fabric_profiles as fp
|
|
8
|
+
resolution = fp.resolve_model_profile(catalogue=catalogue, profiles=profiles, choices={"binding": "default"}, policy=policy)
|
|
9
|
+
configuration = fp.configuration(["runner", "model"], resolution, runner_profile) # well-known `configuration`
|
|
10
|
+
summary += fp.configuration_summary(configuration) # the dashboard's tiles
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence
|
|
21
|
+
|
|
22
|
+
import fabric_secrets as _secrets
|
|
23
|
+
import fabric_service as _fs
|
|
24
|
+
|
|
25
|
+
RESOLUTION_ORDER = ("request", "agent", "binding", "estate")
|
|
26
|
+
# The vendor login each subscription is, and the one runner kind it runs in (DEC-0046, ADR-0147 decision 2).
|
|
27
|
+
SUBSCRIPTION_RUNNER = {"claude": "claude-code", "kimi": "kimi-code", "chatgpt": "codex"}
|
|
28
|
+
MAX_PROBE_MS = 5000
|
|
29
|
+
_BARE_BINARY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ProfileError(ValueError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _list(value: Any) -> List[Dict[str, Any]]:
|
|
37
|
+
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _strings(value: Any) -> List[str]:
|
|
41
|
+
return [item for item in value if isinstance(item, str)] if isinstance(value, list) else []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _js_string(value: Any) -> str:
|
|
45
|
+
"""JavaScript's String(value), which the contract's messages and policy ids use."""
|
|
46
|
+
if value is None:
|
|
47
|
+
return "undefined"
|
|
48
|
+
if value is True:
|
|
49
|
+
return "true"
|
|
50
|
+
if value is False:
|
|
51
|
+
return "false"
|
|
52
|
+
if isinstance(value, float) and value.is_integer():
|
|
53
|
+
return str(int(value))
|
|
54
|
+
if isinstance(value, (dict, list)):
|
|
55
|
+
return "[object Object]" if isinstance(value, dict) else ",".join(_js_string(v) for v in value)
|
|
56
|
+
return str(value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _js_number(value: Any) -> Optional[float]:
|
|
60
|
+
"""JavaScript's Number(value); NaN is None (JSON writes it as null)."""
|
|
61
|
+
if isinstance(value, bool):
|
|
62
|
+
return 1 if value else 0
|
|
63
|
+
if isinstance(value, (int, float)):
|
|
64
|
+
return value if not (isinstance(value, float) and math.isnan(value)) else None
|
|
65
|
+
if isinstance(value, str):
|
|
66
|
+
text = value.strip()
|
|
67
|
+
if text == "":
|
|
68
|
+
return 0
|
|
69
|
+
try:
|
|
70
|
+
number = float(text)
|
|
71
|
+
except ValueError:
|
|
72
|
+
return None
|
|
73
|
+
return int(number) if number.is_integer() else number
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_model_profile(*, catalogue: Any, profiles: Any, choices: Any, policy: Any = None) -> Dict[str, Any]:
|
|
78
|
+
"""FAC-SEM-060: the request's profile, else the agent's, else the binding's or project's, else the estate default.
|
|
79
|
+
When the project's policy names `modelClasses`, a model stays only if every class the catalogue gives it is
|
|
80
|
+
allowed; an empty result refuses the launch, naming the policy."""
|
|
81
|
+
choices = choices if isinstance(choices, dict) else {}
|
|
82
|
+
level = next((name for name in RESOLUTION_ORDER if isinstance(choices.get(name), str)), None)
|
|
83
|
+
if level is None:
|
|
84
|
+
return {"refused": "no-profile"}
|
|
85
|
+
profile_id = choices[level]
|
|
86
|
+
profile = next((item for item in _list(profiles) if item.get("id") == profile_id), None)
|
|
87
|
+
if profile is None:
|
|
88
|
+
return {"refused": "unknown-profile", "profile": profile_id}
|
|
89
|
+
models = _strings(profile.get("models"))
|
|
90
|
+
if not isinstance(policy, dict) or not isinstance(policy.get("modelClasses"), list):
|
|
91
|
+
return {"from": level, "profile": profile_id, "models": models}
|
|
92
|
+
allowed = set(_strings(policy["modelClasses"]))
|
|
93
|
+
rows = {}
|
|
94
|
+
for row in _list(catalogue.get("models") if isinstance(catalogue, dict) else None):
|
|
95
|
+
rows[json.dumps(row.get("id"))] = row
|
|
96
|
+
within = [m for m in models if json.dumps(m) in rows and all(c in allowed for c in _strings(rows[json.dumps(m)].get("classes")))]
|
|
97
|
+
if within:
|
|
98
|
+
return {"from": level, "profile": profile_id, "models": within}
|
|
99
|
+
meta = policy.get("meta") if isinstance(policy.get("meta"), dict) else {}
|
|
100
|
+
return {"refused": "model-classes-empty", "profile": profile_id, "policy": {"id": _js_string(meta.get("id")), "revision": _js_number(meta.get("revision"))}}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _finding(code: str, path: str, message: str) -> Dict[str, str]:
|
|
104
|
+
return {"code": code, "instancePath": path, "message": message}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _model_catalogue(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
108
|
+
findings, seen = [], []
|
|
109
|
+
for index, row in enumerate(_list(value.get("models"))):
|
|
110
|
+
if row.get("id") in seen:
|
|
111
|
+
findings.append(_finding("FAC-SEM-058", "/models/%d/id" % index, "model %s is catalogued more than once" % _js_string(row.get("id"))))
|
|
112
|
+
seen.append(row.get("id"))
|
|
113
|
+
routing = row.get("provider") if isinstance(row.get("provider"), dict) else {}
|
|
114
|
+
ignored = set(_strings(routing.get("ignore")))
|
|
115
|
+
for slug in _strings(routing.get("order")):
|
|
116
|
+
if slug in ignored or slug.split("/")[0] in ignored:
|
|
117
|
+
findings.append(_finding("FAC-SEM-058", "/models/%d/provider" % index, "model %s both orders and ignores provider %s" % (_js_string(row.get("id")), slug)))
|
|
118
|
+
return findings
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _model_profiles(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
122
|
+
findings, seen = [], []
|
|
123
|
+
for index, profile in enumerate(_list(value.get("profiles"))):
|
|
124
|
+
if profile.get("id") in seen:
|
|
125
|
+
findings.append(_finding("FAC-SEM-058", "/profiles/%d/id" % index, "model profile %s is declared more than once" % _js_string(profile.get("id"))))
|
|
126
|
+
seen.append(profile.get("id"))
|
|
127
|
+
return findings
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _runner_profile(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
131
|
+
findings = []
|
|
132
|
+
if value.get("route") == "subscription" and isinstance(value.get("subscription"), dict):
|
|
133
|
+
login = _js_string(value["subscription"].get("login"))
|
|
134
|
+
runner = SUBSCRIPTION_RUNNER.get(login)
|
|
135
|
+
if runner != value.get("runnerKind"):
|
|
136
|
+
findings.append(_finding("FAC-SEM-059", "/runnerKind", "a %s subscription runs only in %s, not in %s; another runner takes the key route"
|
|
137
|
+
% (login, runner if runner is not None else "its vendor's runner", _js_string(value.get("runnerKind")))))
|
|
138
|
+
if value.get("route") == "key" and isinstance(value.get("key"), dict):
|
|
139
|
+
key = value["key"]
|
|
140
|
+
if key.get("modelAllowList") != value.get("modelProfile") or ("modelAllowList" in key) != ("modelProfile" in value):
|
|
141
|
+
findings.append(_finding("FAC-SEM-059", "/key/modelAllowList", "the key admits the models of %s, but the profile runs %s"
|
|
142
|
+
% (_js_string(key.get("modelAllowList")), _js_string(value.get("modelProfile")))))
|
|
143
|
+
if isinstance(key.get("ref"), str) and _secrets.secret_value(key["ref"]):
|
|
144
|
+
findings.append(_finding("FAC-SEM-059", "/key/ref", "the key is named by its secret reference, never by its value"))
|
|
145
|
+
return findings
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _compact(value: Any) -> str:
|
|
149
|
+
return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _profile_resolution(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
153
|
+
findings = []
|
|
154
|
+
catalogue = value.get("catalogue")
|
|
155
|
+
catalogued = [row.get("id") for row in _list(catalogue.get("models") if isinstance(catalogue, dict) else None)]
|
|
156
|
+
ids: List[Any] = []
|
|
157
|
+
for i, profile in enumerate(_list(value.get("profiles"))):
|
|
158
|
+
if profile.get("id") in ids:
|
|
159
|
+
findings.append(_finding("FAC-SEM-060", "/profiles/%d/id" % i, "profile id %s is offered more than once to one resolution" % _js_string(profile.get("id"))))
|
|
160
|
+
ids.append(profile.get("id"))
|
|
161
|
+
for j, model in enumerate(_strings(profile.get("models"))):
|
|
162
|
+
if model not in catalogued:
|
|
163
|
+
findings.append(_finding("FAC-SEM-060", "/profiles/%d/models/%d" % (i, j), "profile %s names %s, which the catalogue does not hold" % (_js_string(profile.get("id")), model)))
|
|
164
|
+
choices = value.get("choices") if isinstance(value.get("choices"), dict) else {}
|
|
165
|
+
expected = resolve_model_profile(catalogue=catalogue, profiles=value.get("profiles"), choices=choices, policy=value.get("policy"))
|
|
166
|
+
if "resolved" not in value or value["resolved"] != expected:
|
|
167
|
+
findings.append(_finding("FAC-SEM-060", "/resolved", "the resolution is %s" % _compact(expected)))
|
|
168
|
+
return findings
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def profile_problems(kind: str, value: Dict[str, Any]) -> Optional[List[Dict[str, str]]]:
|
|
172
|
+
"""The contract's profileRules: findings for a `model-catalogue`, `model-profiles`, `runner-profile` or
|
|
173
|
+
`profile-resolution` document; None for another kind."""
|
|
174
|
+
return {"model-catalogue": _model_catalogue, "model-profiles": _model_profiles, "runner-profile": _runner_profile,
|
|
175
|
+
"profile-resolution": _profile_resolution}.get(kind, lambda v: None)(value)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# --- the doctor's checks ----------------------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def _argv(value: Any, label: str) -> List[str]:
|
|
181
|
+
if not isinstance(value, list) or not all(isinstance(a, str) for a in value):
|
|
182
|
+
raise ProfileError("%s is an argument array, never a shell string" % label)
|
|
183
|
+
return list(value)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def runner_check(entry: Dict[str, Any], *, path: Optional[str] = None,
|
|
187
|
+
run: Callable[..., Any] = subprocess.run) -> Dict[str, Any]:
|
|
188
|
+
"""runners.md C2: is the catalogued runner installed, which version answers, and is it signed in. Only a bare
|
|
189
|
+
catalogued binary found on PATH is run, only with the catalogued version argv (and `auth` argv), without a shell,
|
|
190
|
+
within its timeout (at most 5 s). A catalogue entry outside these rules is refused and nothing runs."""
|
|
191
|
+
kind = entry.get("kind")
|
|
192
|
+
binaries = entry.get("binaries")
|
|
193
|
+
if not isinstance(binaries, list) or not binaries or not all(isinstance(b, str) and _BARE_BINARY.match(b) and ".." not in b for b in binaries):
|
|
194
|
+
raise ProfileError("binaries are bare executable names, never paths")
|
|
195
|
+
version = entry.get("version") if isinstance(entry.get("version"), dict) else {}
|
|
196
|
+
version_argv = _argv(version.get("argv"), "version.argv")
|
|
197
|
+
pattern = version.get("pattern")
|
|
198
|
+
if not isinstance(pattern, str):
|
|
199
|
+
raise ProfileError("version.pattern is a regular expression")
|
|
200
|
+
timeout_ms = version.get("timeoutMs", MAX_PROBE_MS)
|
|
201
|
+
if not isinstance(timeout_ms, int) or isinstance(timeout_ms, bool) or not 0 < timeout_ms <= MAX_PROBE_MS:
|
|
202
|
+
raise ProfileError("version.timeoutMs is at most %d" % MAX_PROBE_MS)
|
|
203
|
+
auth_argv = _argv(entry["auth"], "auth") if "auth" in entry else None
|
|
204
|
+
out: Dict[str, Any] = {"kind": kind, "installed": False, "binary": None, "version": None, "signedIn": None, "problems": []}
|
|
205
|
+
binary = next((found for found in (shutil.which(name, path=path) for name in binaries) if found), None)
|
|
206
|
+
if binary is None:
|
|
207
|
+
out["problems"].append("%s is not installed: none of %s is on PATH" % (kind, ", ".join(binaries)))
|
|
208
|
+
return out
|
|
209
|
+
out.update(installed=True, binary=binary)
|
|
210
|
+
|
|
211
|
+
def probe(argv: List[str]) -> Optional[Any]:
|
|
212
|
+
try:
|
|
213
|
+
return run([binary] + argv, capture_output=True, text=True, timeout=timeout_ms / 1000.0, shell=False)
|
|
214
|
+
except subprocess.TimeoutExpired:
|
|
215
|
+
out["problems"].append("%s %s did not answer within %d ms" % (kind, " ".join(argv), timeout_ms))
|
|
216
|
+
except OSError as exc:
|
|
217
|
+
out["problems"].append("%s could not be run: %s" % (kind, exc.__class__.__name__))
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
answered = probe(version_argv)
|
|
221
|
+
if answered is not None:
|
|
222
|
+
match = re.search(pattern, answered.stdout or "") or re.search(pattern, answered.stderr or "")
|
|
223
|
+
if match and match.groups():
|
|
224
|
+
out["version"] = match.group(1)
|
|
225
|
+
else:
|
|
226
|
+
out["problems"].append("%s answered, but its version did not match the catalogue's pattern" % kind)
|
|
227
|
+
if auth_argv is not None:
|
|
228
|
+
signed = probe(auth_argv)
|
|
229
|
+
if signed is not None:
|
|
230
|
+
out["signedIn"] = signed.returncode == 0
|
|
231
|
+
if not out["signedIn"]:
|
|
232
|
+
out["problems"].append("%s is installed but not signed in" % kind)
|
|
233
|
+
return out
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def model_usable(model: str, catalogue: Any, policy: Any = None) -> List[str]:
|
|
237
|
+
"""Problems with running `model` here: not in the catalogue, or a class the project's policy does not allow."""
|
|
238
|
+
rows = {row.get("id"): row for row in _list(catalogue.get("models") if isinstance(catalogue, dict) else None)}
|
|
239
|
+
if model not in rows:
|
|
240
|
+
return ["model %s is not in the model catalogue" % model]
|
|
241
|
+
if isinstance(policy, dict) and isinstance(policy.get("modelClasses"), list):
|
|
242
|
+
allowed = set(_strings(policy["modelClasses"]))
|
|
243
|
+
outside = [c for c in _strings(rows[model].get("classes")) if c not in allowed]
|
|
244
|
+
if outside:
|
|
245
|
+
meta = policy.get("meta") if isinstance(policy.get("meta"), dict) else {}
|
|
246
|
+
return ["model %s has class %s, which policy %s does not allow" % (model, ", ".join(outside), _js_string(meta.get("id")))]
|
|
247
|
+
return []
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# --- the well-known `configuration` and its tiles --------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
def configuration(slots: Sequence[str], resolution: Dict[str, Any], runner_profile: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
253
|
+
"""The well-known `configuration` (DEC-0046): the configurable slots, and the profiles that apply now. Read-only:
|
|
254
|
+
a change is made in Fabric and reaches the agent through its binding."""
|
|
255
|
+
if "refused" in resolution:
|
|
256
|
+
raise ProfileError("the resolution was refused (%s); a refused resolution is not a configuration" % resolution["refused"])
|
|
257
|
+
resolved: Dict[str, Any] = {"from": resolution.get("from")}
|
|
258
|
+
if runner_profile:
|
|
259
|
+
resolved.update(runnerProfile=runner_profile.get("id"), runnerKind=runner_profile.get("runnerKind"), route=runner_profile.get("route"))
|
|
260
|
+
resolved.update(modelProfile=resolution.get("profile"), models=list(resolution.get("models") or []))
|
|
261
|
+
doc = {"slots": list(slots), "resolved": {k: v for k, v in resolved.items() if v is not None}}
|
|
262
|
+
problems = _fs.configuration_problems(doc)
|
|
263
|
+
if problems:
|
|
264
|
+
raise ProfileError("; ".join(problems))
|
|
265
|
+
return doc
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def configuration_summary(doc: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
269
|
+
"""Summary tiles for the well-known document and the dashboard — "Runner & model"."""
|
|
270
|
+
resolved = doc.get("resolved") or {}
|
|
271
|
+
tiles = []
|
|
272
|
+
if resolved.get("runnerKind"):
|
|
273
|
+
tiles.append({"label": "Runner", "value": " · ".join(x for x in (resolved.get("runnerKind"), resolved.get("route")) if x)})
|
|
274
|
+
if resolved.get("modelProfile"):
|
|
275
|
+
first = (resolved.get("models") or [None])[0]
|
|
276
|
+
tiles.append({"label": "Model", "value": " · ".join(x for x in (resolved["modelProfile"], first) if x)})
|
|
277
|
+
return tiles
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The contract's one credential detector (Fabric Agent Contract DEC-0044, `src/secret-shape.ts` at the pin): a name
|
|
3
|
+
that can only carry a credential, a value shaped like one, a launch argument that carries one, and a URL that does
|
|
4
|
+
(LC-21). A port held to the contract's own answers by test/fixtures/contract/profile-vectors.json. Standard library
|
|
5
|
+
only. The dashboard kit's fabric-button.js carries its own copy of the name and value checks (FL-4)."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Dict, List
|
|
11
|
+
import urllib.parse
|
|
12
|
+
|
|
13
|
+
CREDENTIAL_WORDS = (
|
|
14
|
+
"token", "secret", "password", "passwd", "pass", "passphrase", "credential", "credentials", "cookie", "authorization",
|
|
15
|
+
"bearer", "jwt", "otp", "apikey", "signature", "sig",
|
|
16
|
+
"accesstoken", "idtoken", "authtoken", "refreshtoken", "sessionid", "sessiontoken", "logincode", "authcode", "privatekey",
|
|
17
|
+
"clientsecret", "secretkey", "accesskey",
|
|
18
|
+
)
|
|
19
|
+
CREDENTIAL_PAIRS: Dict[str, tuple] = {
|
|
20
|
+
"key": ("private", "api", "access", "secret", "session", "auth", "signing", "encryption", "master", "client", "login", "refresh"),
|
|
21
|
+
"auth": ("token", "code", "header"),
|
|
22
|
+
"session": ("token", "id"),
|
|
23
|
+
"code": ("login", "auth", "access", "session"),
|
|
24
|
+
}
|
|
25
|
+
JWT_SHAPE = re.compile(r"eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*")
|
|
26
|
+
SECRET_VALUE = re.compile(r"(^bearer\s.+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})",
|
|
27
|
+
re.IGNORECASE | re.DOTALL)
|
|
28
|
+
_LONG_RUN = re.compile(r"[A-Za-z0-9+/=_-]{40,}")
|
|
29
|
+
_BEARER_INSIDE = re.compile(r"\bbearer\s+(?=[A-Za-z0-9._~+/-]*\d)[A-Za-z0-9._~+/-]{12,}=*", re.IGNORECASE)
|
|
30
|
+
_PATH_LIKE = re.compile(r"^(?:[/~.]|[A-Za-z]:[\\/]|[a-z][a-z0-9+.-]*://)", re.IGNORECASE)
|
|
31
|
+
_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def name_words(name: str) -> List[str]:
|
|
35
|
+
"""A name's words: split at `.`, `_`, `-` and camelCase humps, lowercased (`APIKey` is `api key`)."""
|
|
36
|
+
spaced = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name)
|
|
37
|
+
spaced = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", spaced)
|
|
38
|
+
return [w.lower() for w in re.split(r"[\s._-]+", spaced) if w]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _with_singulars(words: List[str]) -> List[str]:
|
|
42
|
+
return words + [w[:-1] for w in words if len(w) > 2 and w.endswith("s")]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def credential_name(name: str) -> bool:
|
|
46
|
+
"""Whether a name — a parameter, a header, an environment variable, a config key — can only carry a credential."""
|
|
47
|
+
split = name_words(name)
|
|
48
|
+
words = _with_singulars(split)
|
|
49
|
+
if any(w in CREDENTIAL_WORDS for w in words):
|
|
50
|
+
return True
|
|
51
|
+
if len(split) == 1 and any(w in CREDENTIAL_PAIRS for w in words):
|
|
52
|
+
return True
|
|
53
|
+
return any(word in words and any(p in words for p in partners) for word, partners in CREDENTIAL_PAIRS.items())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _long_secret(value: str) -> bool:
|
|
57
|
+
return any(re.search(r"[a-z]", run) and re.search(r"[A-Z]", run) and re.search(r"[0-9]", run) for run in _LONG_RUN.findall(value))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def secret_value(value: str) -> bool:
|
|
61
|
+
"""A JWT anywhere, a provider key or `Bearer …`, or a long base64 run mixing upper case, lower case and digits."""
|
|
62
|
+
return bool(JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or _long_secret(value))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _path_like(piece: str) -> bool:
|
|
66
|
+
return bool(_PATH_LIKE.match(piece)) or (
|
|
67
|
+
bool(re.search(r"[\\/]", piece))
|
|
68
|
+
and (bool(re.search(r"\.[A-Za-z0-9]{1,8}$", piece)) or any(re.fullmatch(r"[a-z][a-z0-9_-]{2,}", s) for s in re.split(r"[\\/]", piece))))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def secret_argument(value: str) -> bool:
|
|
72
|
+
"""The same for a launch argument or a server's command and URL: a long run is looked for between path separators,
|
|
73
|
+
so a folder path holding a session id is not taken for a key."""
|
|
74
|
+
if JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or _BEARER_INSIDE.search(value):
|
|
75
|
+
return True
|
|
76
|
+
for piece in re.split(r"[\s=]", value):
|
|
77
|
+
if _path_like(piece):
|
|
78
|
+
if any(_long_secret(part) for part in re.split(r"[\\/]", piece)):
|
|
79
|
+
return True
|
|
80
|
+
elif _long_secret(piece):
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _decode_keeping_plus(raw: str) -> str:
|
|
86
|
+
"""decodeURIComponent: a malformed escape or bytes that are not UTF-8 leave the raw text."""
|
|
87
|
+
if re.search(r"%(?![0-9A-Fa-f]{2})", raw):
|
|
88
|
+
return raw
|
|
89
|
+
try:
|
|
90
|
+
return urllib.parse.unquote_to_bytes(raw).decode("utf-8")
|
|
91
|
+
except UnicodeDecodeError:
|
|
92
|
+
return raw
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _form_name(text: str) -> str:
|
|
96
|
+
pairs = urllib.parse.parse_qsl(text, keep_blank_values=True)
|
|
97
|
+
return pairs[0][0] if pairs else ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def raw_values(query: str) -> Dict[str, List[str]]:
|
|
101
|
+
"""Each query or fragment piece's raw value by its decoded name, with `+` kept."""
|
|
102
|
+
values: Dict[str, List[str]] = {}
|
|
103
|
+
for piece in query.split("&"):
|
|
104
|
+
if piece == "":
|
|
105
|
+
continue
|
|
106
|
+
at = piece.find("=")
|
|
107
|
+
name = _form_name(piece if at < 0 else piece[:at])
|
|
108
|
+
values.setdefault(name, []).append("" if at < 0 else _decode_keeping_plus(piece[at + 1:]))
|
|
109
|
+
return values
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def secret_url(value: str) -> bool:
|
|
113
|
+
"""Whether a URL an MCP entry or a launch names carries a credential (LC-21): anything secret_argument finds, a
|
|
114
|
+
password in its user information, or a query or fragment parameter whose name can only carry a credential and has
|
|
115
|
+
a value, or whose value is shaped like one. A string that is not an absolute URL is checked as an argument only."""
|
|
116
|
+
if secret_argument(value):
|
|
117
|
+
return True
|
|
118
|
+
if not _SCHEME.match(value.strip()):
|
|
119
|
+
return False # not an absolute URL (WHATWG URL parsing refuses it)
|
|
120
|
+
try:
|
|
121
|
+
parts = urllib.parse.urlsplit(value.strip())
|
|
122
|
+
except ValueError:
|
|
123
|
+
return False
|
|
124
|
+
if not parts.scheme:
|
|
125
|
+
return False
|
|
126
|
+
if parts.password:
|
|
127
|
+
return True
|
|
128
|
+
for part in (parts.query, parts.fragment):
|
|
129
|
+
for name, raws in raw_values(part).items():
|
|
130
|
+
if any((raw != "" and credential_name(name)) or secret_value(raw) for raw in raws):
|
|
131
|
+
return True
|
|
132
|
+
return False
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -43,7 +43,7 @@ EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/service
|
|
|
43
43
|
# The embedded dashboard declaration (fabric-dashboard/0.1, DEC-0036 ED-9), under the well-known
|
|
44
44
|
# document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
|
|
45
45
|
DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
|
|
46
|
-
DASHBOARD_KIT = "fabric-dashboard-kit@1.
|
|
46
|
+
DASHBOARD_KIT = "fabric-dashboard-kit@1.1.0"
|
|
47
47
|
REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
|
|
48
48
|
EXIT_ALREADY_RUNNING = 75
|
|
49
49
|
# Lifecycle contract (fabric-workspace knowledge/lifecycle.md). A launchd-supervised copy that finds
|
|
@@ -758,12 +758,45 @@ def dashboard_extension(min_width: int = 360, appearance: Sequence[str] = ("sche
|
|
|
758
758
|
return {DASHBOARD_EXTENSION_KEY: value}
|
|
759
759
|
|
|
760
760
|
|
|
761
|
+
_PROFILE_ID = re.compile(r"^[a-z][a-z0-9-]{0,62}$")
|
|
762
|
+
_RUNNER_KIND = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
|
763
|
+
_MODEL_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._:-]{0,127}$")
|
|
764
|
+
ROUTES = ("subscription", "key")
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def configuration_problems(doc: Any) -> List[str]:
|
|
768
|
+
"""DEC-0046: the well-known `configuration` — {slots, resolved} as service-well-known.schema.json types it."""
|
|
769
|
+
if not isinstance(doc, dict):
|
|
770
|
+
return ["configuration is an object"]
|
|
771
|
+
problems = ["configuration has no member %s" % k for k in doc if k not in ("slots", "resolved")]
|
|
772
|
+
slots = doc.get("slots")
|
|
773
|
+
if not isinstance(slots, list) or len(slots) > 2 or len(set(map(str, slots))) != len(slots) or not all(s in ("runner", "model") for s in slots):
|
|
774
|
+
problems.append("slots is a list of runner and model, each once")
|
|
775
|
+
resolved = doc.get("resolved")
|
|
776
|
+
if not isinstance(resolved, dict):
|
|
777
|
+
return problems + ["resolved is an object"]
|
|
778
|
+
for k, v in resolved.items():
|
|
779
|
+
ok = {"from": lambda x: x in ("request", "agent", "binding", "estate"),
|
|
780
|
+
"runnerProfile": lambda x: isinstance(x, str) and bool(_PROFILE_ID.match(x)),
|
|
781
|
+
"modelProfile": lambda x: isinstance(x, str) and bool(_PROFILE_ID.match(x)),
|
|
782
|
+
"runnerKind": lambda x: isinstance(x, str) and bool(_RUNNER_KIND.match(x)),
|
|
783
|
+
"route": lambda x: x in ROUTES,
|
|
784
|
+
"models": lambda x: isinstance(x, list) and len(x) <= 16 and all(isinstance(m, str) and _MODEL_ID.match(m) for m in x)}.get(k)
|
|
785
|
+
if ok is None:
|
|
786
|
+
problems.append("resolved has no member %s" % k)
|
|
787
|
+
elif not ok(v):
|
|
788
|
+
problems.append("resolved.%s is not valid" % k)
|
|
789
|
+
return problems
|
|
790
|
+
|
|
791
|
+
|
|
761
792
|
def build_well_known(*, service_id: str, instance: str, name: str, version: str, build: Dict[str, Any],
|
|
762
793
|
started_at: str, status: str, degraded: Sequence[Dict[str, str]],
|
|
763
794
|
surfaces: Dict[str, Any], summary: Optional[Sequence[Dict[str, Any]]] = None,
|
|
764
795
|
update_available: Optional[str] = None, pid: Optional[int] = None,
|
|
765
|
-
extensions: Optional[Dict[str, Any]] = None
|
|
796
|
+
extensions: Optional[Dict[str, Any]] = None,
|
|
797
|
+
configuration: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
766
798
|
"""The unauthenticated identity answer. `degraded` is always present; ready means empty.
|
|
799
|
+
`configuration` (DEC-0046) says what the operator can configure and what applies now; fabric_profiles builds it.
|
|
767
800
|
`extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration
|
|
768
801
|
in it must be valid (dashboard_extension builds one)."""
|
|
769
802
|
if status not in STATUSES:
|
|
@@ -790,6 +823,11 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
790
823
|
if problems:
|
|
791
824
|
raise ServiceError("Dashboard declaration: %s." % "; ".join(problems))
|
|
792
825
|
doc["extensions"] = dict(extensions)
|
|
826
|
+
if configuration is not None:
|
|
827
|
+
problems = configuration_problems(configuration)
|
|
828
|
+
if problems:
|
|
829
|
+
raise ServiceError("configuration: %s." % "; ".join(problems))
|
|
830
|
+
doc["configuration"] = configuration
|
|
793
831
|
return doc
|
|
794
832
|
|
|
795
833
|
|
|
@@ -896,7 +934,7 @@ _PROVIDER = re.compile(r"^[a-z][a-z0-9._-]{0,63}$")
|
|
|
896
934
|
def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_tokens: int,
|
|
897
935
|
cache_read_tokens: int = 0, cache_write_tokens: int = 0,
|
|
898
936
|
cost_usd: Optional[float] = None, cost_basis: Optional[str] = None,
|
|
899
|
-
at: Optional[str] = None) -> Dict[str, Any]:
|
|
937
|
+
at: Optional[str] = None, route: Optional[str] = None) -> Dict[str, Any]:
|
|
900
938
|
"""One model call, from the provider's own usage numbers. No prompt, output or caller.
|
|
901
939
|
|
|
902
940
|
`cost_usd` is None when the call cannot be priced: the report counts it as unpriced and
|
|
@@ -917,8 +955,13 @@ def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_t
|
|
|
917
955
|
basis = cost_basis or ("unknown" if cost_usd is None else "provider")
|
|
918
956
|
if basis not in COST_BASES or (cost_usd is None) != (basis == "unknown"):
|
|
919
957
|
raise ServiceError("cost_basis is provider or price-list with a cost, unknown without one.")
|
|
920
|
-
|
|
921
|
-
|
|
958
|
+
if route is not None and route not in ROUTES:
|
|
959
|
+
raise ServiceError("route is subscription or key (DEC-0046).")
|
|
960
|
+
receipt = {"at": at or now_iso(), "provider": provider, "model": model, **counts,
|
|
961
|
+
"costUsd": None if cost_usd is None else float(cost_usd), "costBasis": basis}
|
|
962
|
+
if route is not None:
|
|
963
|
+
receipt["route"] = route # the operator's own login in its vendor's runner, or an issued key
|
|
964
|
+
return receipt
|
|
922
965
|
|
|
923
966
|
|
|
924
967
|
def _add_totals(row: Dict[str, Any], receipt: Dict[str, Any]) -> None:
|
|
@@ -952,20 +995,22 @@ def usage_report(receipts: Iterable[Dict[str, Any]], *, service_id: str, instanc
|
|
|
952
995
|
date = str(r.get("at", ""))[:10]
|
|
953
996
|
if not (first <= date <= last):
|
|
954
997
|
continue
|
|
955
|
-
key = (r["provider"], r["model"])
|
|
956
|
-
row = days.setdefault(date, {}).setdefault("%s\u0000%s" % key, {
|
|
998
|
+
key = (r["provider"], r["model"], r.get("route") or "")
|
|
999
|
+
row = days.setdefault(date, {}).setdefault("%s\u0000%s\u0000%s" % key, {
|
|
957
1000
|
"provider": r["provider"], "model": r["model"], "calls": 0, "unpricedCalls": 0,
|
|
958
1001
|
"inputTokens": 0, "outputTokens": 0, "cacheReadTokens": 0, "cacheWriteTokens": 0, "_cost": 0.0})
|
|
1002
|
+
if r.get("route"):
|
|
1003
|
+
row["route"] = r["route"] # DEC-0046: rows of one model on two routes stay apart
|
|
959
1004
|
_add_totals(row, r)
|
|
960
1005
|
bases.setdefault((date,) + key, set()).add(r.get("costBasis") or "unknown")
|
|
961
1006
|
out_days = []
|
|
962
1007
|
for date in sorted(days):
|
|
963
1008
|
models = []
|
|
964
1009
|
for row in days[date].values():
|
|
965
|
-
seen = bases[(date, row["provider"], row["model"])]
|
|
1010
|
+
seen = bases[(date, row["provider"], row["model"], row.get("route") or "")]
|
|
966
1011
|
row["costBasis"] = next(iter(seen)) if len(seen) == 1 else "mixed"
|
|
967
1012
|
models.append(_close_totals(row))
|
|
968
|
-
models.sort(key=lambda m: (m["provider"], m["model"]))
|
|
1013
|
+
models.sort(key=lambda m: (m["provider"], m["model"], m.get("route") or ""))
|
|
969
1014
|
day = {"date": date, "calls": 0, "unpricedCalls": 0, "inputTokens": 0, "outputTokens": 0,
|
|
970
1015
|
"cacheReadTokens": 0, "cacheWriteTokens": 0, "_cost": 0.0}
|
|
971
1016
|
for m in models:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py
CHANGED
|
@@ -33,10 +33,12 @@ VERSION = "0.1.0"
|
|
|
33
33
|
# in the well-known document only when the kit is there to serve.
|
|
34
34
|
KIT_DIR = Path(__file__).resolve().parent.parent / "assets" / "dashboard-kit"
|
|
35
35
|
KIT_FILES = {"/kit/fabric-dashboard.css": ("fabric-dashboard.css", "text/css; charset=utf-8"),
|
|
36
|
-
"/kit/fabric-dashboard.js": ("fabric-dashboard.js", "text/javascript; charset=utf-8")
|
|
36
|
+
"/kit/fabric-dashboard.js": ("fabric-dashboard.js", "text/javascript; charset=utf-8"),
|
|
37
|
+
"/kit/fabric-button.js": ("fabric-button.js", "text/javascript; charset=utf-8")} # DEC-0040, FB-7
|
|
37
38
|
PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
38
39
|
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{name}</title>
|
|
39
40
|
<link rel="stylesheet" href="/kit/fabric-dashboard.css"><script src="/kit/fabric-dashboard.js" defer></script>
|
|
41
|
+
<script src="/kit/fabric-button.js" defer data-service="{agent}" data-service-name="{name}"></script>
|
|
40
42
|
</head><body><div class="fd-chrome"><header class="fd-header"><p class="fd-title">{name}</p>
|
|
41
43
|
<span class="fd-chip fd-s-ok"><span class="fd-dot"></span>Status: {status}</span></header>
|
|
42
44
|
<nav class="fd-nav" aria-label="Sections"><ul class="fd-nav-list"><li><a href="#events" aria-current="page">Events</a></li>
|
|
@@ -227,7 +229,7 @@ def make_handler(svc: Service):
|
|
|
227
229
|
return self._send(401, "Open this dashboard from Fabric Dashboards.", content_type="text/plain")
|
|
228
230
|
rows = "".join("<li>%s — %s</li>" % (html.escape(e["at"]), html.escape(e["text"]))
|
|
229
231
|
for e in reversed(svc.log.fetch(None, 20)))
|
|
230
|
-
return self._send(200, PAGE.format(name=html.escape(svc.name), status="ready", rows=rows),
|
|
232
|
+
return self._send(200, PAGE.format(name=html.escape(svc.name), agent=html.escape("%s.%s" % (svc.id, svc.instance)), status="ready", rows=rows),
|
|
231
233
|
content_type="text/html; charset=utf-8")
|
|
232
234
|
self._send(404, {"error": "Not found."})
|
|
233
235
|
|
|
@@ -5,7 +5,7 @@ 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.16.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "0cacd08b0e95b6abf131eae1206b60792138bc3c"
|
|
11
11
|
---
|