@passioncode-ai/fabric-agent-adapter 0.15.0 → 0.17.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 (21) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +2 -2
  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 +7 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +2 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +59 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +5 -0
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +132 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +3 -2
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +64 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +3 -1
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +196 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +86 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +40 -6
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +277 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +132 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +53 -8
  21. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
@@ -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
@@ -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) -> Dict[str, Any]:
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
- return {"at": at or now_iso(), "provider": provider, "model": model, **counts,
921
- "costUsd": None if cost_usd is None else float(cost_usd), "costBasis": basis}
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:
@@ -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.15.0"
8
+ version: "0.17.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "0cacd08b0e95b6abf131eae1206b60792138bc3c"
11
11
  ---