@passioncode-ai/passioncode 0.1.39 → 0.1.41

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 (24) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -1
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +2 -1
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +59 -0
  10. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +5 -0
  11. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +132 -0
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +3 -2
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +64 -0
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +3 -1
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -0
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +196 -0
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +86 -0
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +40 -6
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +277 -0
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +132 -0
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +53 -8
  23. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  24. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.41 - 2026-10-12
4
+
5
+ ### Changed
6
+
7
+ - **Fabric Agent Adapter is pinned at `v0.17.0`** (it was `v0.16.0`). The organisation's lifecycle rules LC-19 and LC-21 are now enforced by the adapter's checks. No plugin may start a stdio MCP server in every session, and no credential may appear in argv or a URL query. The checks are in adapt_project.py check, fabric_provider.py and the adapter's own validate gate.
8
+
9
+ ## 0.1.40 - 2026-10-12
10
+
11
+ ### Changed
12
+
13
+ - **Fabric Agent Adapter is pinned at `v0.16.0`** (it was `v0.15.0`). Runner and model profiles in both kits (contract DEC-0046). The kit resolves the model profile in the contract's order within the project's model classes and declares what applies in the well-known configuration, with Runner and Model tiles. It checks the runner and model in the doctor and records the route on usage. The contract's credential detector is ported.
14
+
3
15
  ## 0.1.39 - 2026-10-12
4
16
 
5
17
  ### Changed
package/family.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "name": "fabric-agent-adapter",
9
9
  "displayName": "Fabric Agent Adapter",
10
10
  "repo": "passioncode-ai/fabric-agent-adapter",
11
- "ref": "v0.15.0",
11
+ "ref": "v0.17.0",
12
12
  "kind": "plugin",
13
13
  "path": "plugins/fabric-agent-adapter",
14
14
  "legacyPluginIds": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/passioncode",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "description": "The PassionCode.ai agent skill set \u2014 Fabric Agent Adapter, Observatory Log and the organisation's working rules \u2014 for Claude Code and every other agent on the machine, updated as one set.",
5
5
  "bin": {
6
6
  "passioncode": "bin/passioncode.js"
@@ -11,7 +11,7 @@
11
11
  "name": "fabric-agent-adapter",
12
12
  "displayName": "Fabric Agent Adapter",
13
13
  "source": "./plugins/fabric-agent-adapter",
14
- "version": "0.15.0",
14
+ "version": "0.17.0",
15
15
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
16
16
  "author": {
17
17
  "name": "PassionCode.ai",
@@ -35,7 +35,7 @@
35
35
  "name": "passioncode",
36
36
  "displayName": "PassionCode.ai",
37
37
  "source": "./plugins/passioncode",
38
- "version": "0.1.39",
38
+ "version": "0.1.41",
39
39
  "description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
40
40
  "author": {
41
41
  "name": "PassionCode.ai",
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "family": "passioncode",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "release": true,
5
5
  "members": [
6
6
  {
7
7
  "name": "fabric-agent-adapter",
8
8
  "displayName": "Fabric Agent Adapter",
9
9
  "repo": "passioncode-ai/fabric-agent-adapter",
10
- "ref": "v0.15.0",
11
- "commit": "00dafeb7ce12ac366c458367116710891250017b",
12
- "version": "0.15.0",
10
+ "ref": "v0.17.0",
11
+ "commit": "b943ea2db8b20aa875ffe54f6d6129319e65cbe2",
12
+ "version": "0.17.0",
13
13
  "via": "clone of https://***@github.com/passioncode-ai/fabric-agent-adapter.git",
14
14
  "skills": [
15
15
  "adapting-projects-to-fabric",
@@ -22,7 +22,7 @@
22
22
  "legacyMarketplaces": [
23
23
  "fabric-agent-adapter"
24
24
  ],
25
- "contentHash": "sha256:7a205025f1c6674eec48be3942affbcd268294f5b71da8d716ea7d2b5c2ec75d",
25
+ "contentHash": "sha256:14a3bfd965a38a0369c61f233ad2eca628bdc1b73b92aedf5d0b6cee1b6bd545",
26
26
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
27
27
  "author": {
28
28
  "name": "PassionCode.ai",
@@ -61,16 +61,16 @@
61
61
  "name": "passioncode",
62
62
  "displayName": "PassionCode.ai",
63
63
  "repo": "passioncode-ai/passioncode",
64
- "ref": "v0.1.39",
64
+ "ref": "v0.1.41",
65
65
  "commit": null,
66
- "version": "0.1.39",
66
+ "version": "0.1.41",
67
67
  "via": "this repository",
68
68
  "skills": [
69
69
  "working-in-passioncode"
70
70
  ],
71
71
  "legacyPluginIds": [],
72
72
  "legacyMarketplaces": [],
73
- "contentHash": "sha256:688b1fd3406af52c9b78ac7107f9faa1e8858d7e3688547f32f76812b8c6bc60",
73
+ "contentHash": "sha256:acfd250d8e14df88268501d8d23bea2df13a5245a16ff2f9e573d92129d0cd24",
74
74
  "description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
75
75
  "author": {
76
76
  "name": "PassionCode.ai",
@@ -3,7 +3,7 @@
3
3
  "name": "fabric-agent-adapter",
4
4
  "displayName": "Fabric Agent Adapter",
5
5
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
6
- "version": "0.15.0",
6
+ "version": "0.17.0",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -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.
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
  ---
@@ -173,6 +173,12 @@ python3 <skill-dir>/scripts/adapt_project.py check <project-root> \
173
173
  --contract /path/to/fabric-agent-contract --json
174
174
  ```
175
175
 
176
+ The local check also refuses two lifecycle faults (LC-19, LC-21): a Claude Code plugin in
177
+ the project that declares a stdio MCP server — it starts in every session that enables the
178
+ plugin; serve it over HTTP from one resident process or register it at project scope — and a
179
+ credential in a launch argument or a URL query, where every process and every log can read it.
180
+ Fix both at their cause; never weaken the check.
181
+
176
182
  Load [verification](references/verification.md) before interpreting or publishing the
177
183
  result. Fix every local error. Fix every placeholder warning or record why the bundle is
178
184
  still a draft. Do not convert `NOT_RUN` or `NOT_VERIFIED` to `PASS` without the named
@@ -35,7 +35,8 @@ python3 scripts/fabric_provider.py remove example-agent # the uninstaller
35
35
  `--stdio` takes the rest of the line as the argument array; there is no shell string. An entry may carry
36
36
  `run.mcp.stdio.scope` (DEC-0044): `project` (started for the sessions of a project that binds it) or `on-demand`;
37
37
  `session` — a server in every coding session — is refused (FAC-SEM-056, LC-19). The validator accepts the field; the
38
- writer does not set it until Fabric reads it.
38
+ writer does not set it until Fabric reads it. An argument that carries a credential is refused
39
+ (LC-21, FAC-SEM-057): a key goes in `env` as a `secret-ref:<name>`, never in argv.
39
40
 
40
41
  It refuses, with one sentence that never quotes a value:
41
42
 
@@ -15,6 +15,9 @@ import sys
15
15
  from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
16
16
  from urllib.parse import urlparse
17
17
 
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
19
+ import fabric_secrets as _secrets # noqa: E402 - the contract's credential detector (DEC-0044, LC-21), beside this file
20
+
18
21
 
19
22
  CONTRACT_VERSION = "0.1.0"
20
23
  CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
@@ -230,6 +233,8 @@ def _generated_files(args: argparse.Namespace) -> Dict[str, str]:
230
233
  lowered = runtime_arg.lower().replace("_", "").replace("-", "")
231
234
  if any(term in lowered for term in ("password", "apikey", "accesstoken", "clientsecret")):
232
235
  raise AdaptationError("runtime arguments must not contain secret-like values or names")
236
+ if _secrets.secret_argument(runtime_arg):
237
+ raise AdaptationError("a runtime argument carries a credential; argv is visible to every process (LC-21): read it from a secret reference instead")
233
238
 
234
239
  created_at = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
235
240
  manifest = {
@@ -465,6 +470,58 @@ import {SCHEMA_PREFIX} from './src/contract.ts';
465
470
  return "PASS", ["manifest validated by selected contract schema at %s" % selected_commit]
466
471
 
467
472
 
473
+ def _credential_problems(manifest: Dict[str, Any]) -> List[str]:
474
+ """LC-21 (FAA-23): no credential in a launch argument or in a URL the manifest names."""
475
+ problems: List[str] = []
476
+ for index, capability in enumerate(manifest.get("capabilities") or []):
477
+ connection = ((capability or {}).get("profile") or {}).get("connection") if isinstance(capability, dict) else None
478
+ if not isinstance(connection, dict):
479
+ continue
480
+ for arg in connection.get("args") or []:
481
+ if isinstance(arg, str) and _secrets.secret_argument(arg):
482
+ problems.append("LC-21: capability[%d] passes a credential in a launch argument; argv is visible to every process" % index)
483
+ for field in ("url", "agentCardUrl"):
484
+ if isinstance(connection.get(field), str) and _secrets.secret_url(connection[field]):
485
+ problems.append("LC-21: capability[%d].%s carries a credential in the URL; a query lands in every log" % (index, field))
486
+ return problems
487
+
488
+
489
+ _PLUGIN_SKIP = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build", ".worktrees"}
490
+
491
+
492
+ def _plugin_problems(root: Path) -> List[str]:
493
+ """LC-19 (FAA-22) and LC-21 for the Claude Code plugins in the project: a plugin's stdio MCP server starts in
494
+ every session that enables the plugin; serve it over HTTP from one resident process, or register it at project
495
+ scope instead. No server entry carries a credential in its argv, URL or env."""
496
+ problems: List[str] = []
497
+ for top, dirs, files in os.walk(root):
498
+ dirs[:] = [d for d in dirs if d not in _PLUGIN_SKIP]
499
+ here = Path(top)
500
+ if here.name != ".claude-plugin" or "plugin.json" not in files:
501
+ continue
502
+ plugin = here.parent
503
+ name = plugin.relative_to(root).as_posix() or "."
504
+ servers: Dict[str, Any] = {}
505
+ for source in (here / "plugin.json", plugin / ".mcp.json"):
506
+ try:
507
+ doc = json.loads(source.read_text(encoding="utf-8")) if source.is_file() else {}
508
+ except (OSError, ValueError):
509
+ continue
510
+ block = doc.get("mcpServers") if isinstance(doc, dict) else None
511
+ if isinstance(block, dict):
512
+ servers.update({k: v for k, v in block.items() if isinstance(v, dict)})
513
+ for server, entry in sorted(servers.items()):
514
+ if "command" in entry:
515
+ problems.append("LC-19: plugin %s declares the stdio MCP server %s, which starts in every session that enables the plugin; serve it over HTTP from one resident process or register it at project scope" % (name, server))
516
+ argv = [entry.get("command")] + list(entry.get("args") or [])
517
+ if any(isinstance(a, str) and _secrets.secret_argument(a) for a in argv) or (isinstance(entry.get("url"), str) and _secrets.secret_url(entry["url"])):
518
+ problems.append("LC-21: plugin %s's MCP server %s carries a credential in its argv or URL" % (name, server))
519
+ env = entry.get("env") if isinstance(entry.get("env"), dict) else {}
520
+ if any(isinstance(v, str) and _secrets.secret_value(v) for v in env.values()):
521
+ problems.append("LC-21: plugin %s's MCP server %s writes a credential value in env; reference it instead" % (name, server))
522
+ return problems
523
+
524
+
468
525
  def check_project(root: Path, contract: Optional[Path]) -> Dict[str, Any]:
469
526
  errors: List[str] = []
470
527
  warnings: List[str] = []
@@ -534,6 +591,7 @@ def check_project(root: Path, contract: Optional[Path]) -> Dict[str, Any]:
534
591
  errors.append("secret-like field is forbidden in manifest: %s" % key)
535
592
  if isinstance(value, str) and ("replace-me" in value or ".invalid" in value):
536
593
  warnings.append("placeholder:%s" % key)
594
+ errors.extend(_credential_problems(manifest))
537
595
  provider = manifest.get("provider")
538
596
  if isinstance(provider, dict) and provider.get("contentHash") == "sha256:" + ("0" * 64):
539
597
  warnings.append("placeholder:provider.contentHash")
@@ -545,6 +603,7 @@ def check_project(root: Path, contract: Optional[Path]) -> Dict[str, Any]:
545
603
  elif contract is not None and manifest_path.is_file():
546
604
  shape_status, shape_receipts = _contract_shape(manifest_path, contract, lock["commit"])
547
605
 
606
+ errors.extend(_plugin_problems(root))
548
607
  local_status = "PASS" if not errors else "FAIL"
549
608
  ready = local_status == "PASS" and shape_status == "PASS" and not warnings
550
609
  return {
@@ -29,6 +29,9 @@ import sys
29
29
  import tempfile
30
30
  from typing import Any, Dict, List, Optional
31
31
 
32
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
33
+ import fabric_secrets as _secrets # noqa: E402 - the contract's credential detector (DEC-0044, LC-21), beside this file
34
+
32
35
  PROTOCOL = "fabric-provider/0.1"
33
36
  _ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
34
37
  _ENV_NAME = re.compile(r"^[A-Z_][A-Z0-9_]*$")
@@ -124,6 +127,8 @@ def _run_problems(run: Any) -> List[str]:
124
127
  command = stdio["command"]
125
128
  if not isinstance(command, list) or not 1 <= len(command) <= 32 or not all(isinstance(a, str) and a for a in command):
126
129
  problems.append("run.mcp.stdio.command is an argument array, never a shell string")
130
+ elif any(_secrets.secret_argument(a) for a in command):
131
+ problems.append("run.mcp.stdio.command carries a credential in an argument; argv is visible to every process (LC-21, FAC-SEM-057): pass it as a secret reference in env")
127
132
  env = stdio.get("env", {})
128
133
  if not isinstance(env, dict):
129
134
  return problems + ["run.mcp.stdio.env is an object"]
@@ -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
@@ -16,7 +16,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
16
16
  compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only (Linux services use lifecycle manager none until a systemd adapter exists). Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
17
17
  metadata:
18
18
  author: PassionCode.ai
19
- version: "0.15.0"
19
+ version: "0.17.0"
20
20
  contract-version: "0.1.0"
21
21
  extension: "fabric-service/0.1"
22
22
  extension-commit: "0cacd08b0e95b6abf131eae1206b60792138bc3c"
@@ -83,7 +83,8 @@ are in [`scripts/fabric_interop.py`](scripts/fabric_interop.py) and
83
83
  [`scripts/fabric-interop.mjs`](scripts/fabric-interop.mjs). When the callers are agents of the estate, the
84
84
  service checks each call's delegation token (DEC-0039): read [the delegation reference](references/delegation.md);
85
85
  the verifier is [`scripts/fabric_delegation.py`](scripts/fabric_delegation.py) /
86
- [`scripts/fabric-delegation.mjs`](scripts/fabric-delegation.mjs).
86
+ [`scripts/fabric-delegation.mjs`](scripts/fabric-delegation.mjs). When Fabric chooses the service's runner and models
87
+ (DEC-0046), read [the profiles reference](references/profiles.md).
87
88
 
88
89
  ## Step 2 — the non-negotiables
89
90
 
@@ -0,0 +1,64 @@
1
+ # Runner and model profiles (DEC-0046)
2
+
3
+ Which coding agent runs a job (the **runner**) and which models it may use (the **model profile**) are chosen in
4
+ Fabric, not in the service. The service resolves them in the contract's order, keeps to the project's model classes,
5
+ says what applies in its well-known document, and checks in its doctor that the runner and the model are usable.
6
+ Contract: `docs/specification/runners.md` §"Runner and model profiles" and `src/profile-rules.ts` at the pin.
7
+ Python: [`scripts/fabric_profiles.py`](../scripts/fabric_profiles.py); Node:
8
+ [`scripts/fabric-profiles.mjs`](../scripts/fabric-profiles.mjs). Both answer the contract's own vectors.
9
+
10
+ ## Contents
11
+
12
+ - Resolving the model profile
13
+ - Checking the documents
14
+ - Declaring and showing what applies
15
+ - The doctor
16
+ - Usage by route
17
+
18
+ ## Resolving the model profile
19
+
20
+ ```python
21
+ import fabric_profiles as fp
22
+ resolution = fp.resolve_model_profile(catalogue=catalogue, profiles=estate_and_agent_profiles,
23
+ choices={"request": asked, "agent": own_default, "binding": bound, "estate": estate_default},
24
+ policy=project_spend_policy)
25
+ ```
26
+
27
+ The first level that names a profile wins: **request, agent, binding or project, estate default**. The project's
28
+ `modelClasses` narrow the profile's models — a model stays only if every class the catalogue gives it is allowed — and
29
+ never choose one. An empty result is `{"refused": "model-classes-empty", "policy": {id, revision}}`: refuse the launch
30
+ and name the policy; never fall back to a model outside it. No level at all is `no-profile`; an id nobody offers is
31
+ `unknown-profile`.
32
+
33
+ ## Checking the documents
34
+
35
+ `profile_problems(kind, doc)` / `profileProblems(kind, doc)` returns the contract's findings for a `model-catalogue`
36
+ (a model catalogued once; a provider never both ordered and ignored, a base slug covering its variants), a
37
+ `model-profiles` set (an id once), a `runner-profile` (a subscription only in its vendor's runner — Claude in Claude
38
+ Code, Kimi in Kimi Code, ChatGPT in Codex; a key route's allow-list is its own model profile; a key named by reference,
39
+ never by value) and a recorded `profile-resolution` (it equals the order's answer).
40
+
41
+ ## Declaring and showing what applies
42
+
43
+ ```python
44
+ conf = fp.configuration(["runner", "model"], resolution, runner_profile)
45
+ well_known = fs.build_well_known(..., configuration=conf, summary=fp.configuration_summary(conf) + other_tiles)
46
+ ```
47
+
48
+ `configuration` is read-only: a change is made in Fabric and reaches the service through its binding, never through
49
+ the well-known document. `configuration_summary` gives the **Runner** and **Model** tiles ("claude-code ·
50
+ subscription", "default · deepseek/deepseek-v4-pro") that the dashboard shows beside the others. The probe checks the
51
+ declaration (`configuration.declared`).
52
+
53
+ ## The doctor
54
+
55
+ `runner_check(entry)` / `runnerCheck(entry)` takes one entry of Fabric's runner catalogue (`registry/runners.json`)
56
+ and answers whether the runner is installed, which version answers and whether it is signed in. It runs only a bare
57
+ catalogued binary found on `PATH`, only with the catalogued version argv (and `auth` argv), without a shell, within
58
+ the entry's timeout (at most 5 s); an entry outside these rules is refused and nothing runs. `model_usable(model,
59
+ catalogue, policy)` names why a model cannot be used here: not catalogued, or a class the project does not allow.
60
+
61
+ ## Usage by route
62
+
63
+ `make_usage_receipt(..., route="subscription"|"key")` records how the call was paid — the operator's own login in its
64
+ vendor's runner, or an issued key. The usage report keeps the rows of one model on two routes apart.
@@ -63,7 +63,7 @@ No auth; the Host/Origin guard applies; answered from memory in under 100 ms.
63
63
  entries through unchanged.
64
64
  - `configuration` (DEC-0046) MAY say which runner and model profile the service resolved and from where
65
65
  (`request`, `agent`, `binding`, `estate`); a usage receipt MAY carry the `route` (`subscription` or `key`). The kits
66
- do not emit either yet (FAA-19).
66
+ build both: `fabric_profiles.configuration` and `make_usage_receipt(route=)` ([profiles](profiles.md)).
67
67
  - `build` needs `commit` or `digest` (`sha256:<64 hex>`).
68
68
  - `summary`: at most six tiles; `attention: true` counts toward the host's badge.
69
69
  - `surfaces.events` is required; `dashboard` and `mcp` when the service has them; `usage`
@@ -21,7 +21,9 @@
21
21
  (`<tool> mcp-register`), which writes `{"type":"http","url":"http://127.0.0.1:<port>/mcp","headers":{...}}`
22
22
  atomically, aborts if the file changed meanwhile, and keeps one 0600 backup. The token
23
23
  goes in `headers`, never in the URL — every log that records a URL prints its query
24
- string.
24
+ string. An HTTP registration starts nothing per session: every client session talks to the
25
+ one resident service (LC-19). A stdio server, by contrast, is a process per session — keep
26
+ one only at project scope.
25
27
  - Ship server `instructions` that tell a caller where to start and that every answer
26
28
  carries `degraded`.
27
29
 
@@ -245,6 +245,7 @@ class Probe:
245
245
  if "events" not in (wk.get("surfaces") or {}):
246
246
  problems.append("surfaces.events missing")
247
247
  self.add("well-known.shape", "FAIL" if problems else "PASS", "; ".join(problems) or "protocol, build, status, degraded, surfaces")
248
+ self.configuration_rule()
248
249
  svc = wk.get("service") or {}
249
250
  answered = "%s.%s" % (svc.get("id"), svc.get("instance"))
250
251
  expected = "%s.%s" % (self.d.get("id"), self.d.get("instance", "default"))
@@ -524,6 +525,16 @@ class Probe:
524
525
  self.trace_rule(listing, sent)
525
526
  self.events_trace_rule(self.events)
526
527
 
528
+ def configuration_rule(self) -> None:
529
+ """DEC-0046: a well-known `configuration` is {slots, resolved} as the schema types it; absent is NOT_RUN."""
530
+ if not isinstance(self.wk, dict) or "configuration" not in self.wk:
531
+ self.add("configuration.declared", "NOT_RUN", "the service declares no runner or model configuration")
532
+ return
533
+ problems = fs.configuration_problems(self.wk["configuration"])
534
+ self.add("configuration.declared", "FAIL" if problems else "PASS", "; ".join(problems) or
535
+ "slots %s; resolved from %s" % (", ".join(self.wk["configuration"].get("slots") or []) or "none",
536
+ (self.wk["configuration"].get("resolved") or {}).get("from", "not stated")))
537
+
527
538
  def delegation_declared_rule(self, mcp_surface: Optional[Dict[str, Any]]) -> None:
528
539
  """DL-16 (DEC-0039): a service that accepts delegation tokens names the issuer it trusts and its JWKS
529
540
  in auth.delegation; with surfaces.mcp.auth delegation that declaration is required."""