@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.
- package/CHANGELOG.md +58 -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 +7 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +2 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +59 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +5 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +132 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +3 -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/references/surfaces-and-auth.md +3 -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/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 +40 -6
- 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 +53 -8
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.0 - 2026-10-12
|
|
4
|
+
|
|
5
|
+
The organisation's lifecycle rules LC-19 and LC-21 apply to what the adapter ships and checks (FAA-22, FAA-23):
|
|
6
|
+
no plugin starts a stdio MCP server in every session, and no credential travels in argv or a URL query.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`adapt_project.py check`** refuses:
|
|
11
|
+
- **LC-19:** a Claude Code plugin in the project that declares a stdio MCP server, whether in its `.mcp.json` or in
|
|
12
|
+
`plugin.json` `mcpServers`, because it starts in every session that enables the plugin. An HTTP entry to a
|
|
13
|
+
resident service passes.
|
|
14
|
+
- **LC-21:** a launch argument or a URL that carries a credential, in the manifest's connections and in the plugin
|
|
15
|
+
servers' argv, URL and env.
|
|
16
|
+
|
|
17
|
+
`scaffold` refuses a runtime argument that carries a credential.
|
|
18
|
+
- **`fabric_provider.py`** refuses a credential in `run.mcp.stdio.command` (LC-21, FAC-SEM-057). The key goes in
|
|
19
|
+
`env` as a secret reference.
|
|
20
|
+
- **`test/validate.py` gate:** the adapter's own plugin declares no MCP server and no hook command carries a
|
|
21
|
+
credential.
|
|
22
|
+
- The detector is the contract's (`fabric_secrets`, 0.16.0). The adapting skill carries an identical copy, so it
|
|
23
|
+
works installed alone, and a test keeps the two copies byte-identical.
|
|
24
|
+
- Docs: the adapting skill's check step, `provider-entry.md`, and `surfaces-and-auth.md` (an HTTP registration
|
|
25
|
+
starts nothing per session). The scenario eval `plugin-and-argv-hygiene` came first. Five planted defects were
|
|
26
|
+
each caught.
|
|
27
|
+
|
|
28
|
+
## 0.16.0 - 2026-10-12
|
|
29
|
+
|
|
30
|
+
Runner and model profiles in both kits (contract DEC-0046, Fabric ADR-0147; FAA-19). The contract's credential
|
|
31
|
+
detector (DEC-0044) is ported too. The contract pin is unchanged (`0cacd08`).
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **`scripts/fabric_profiles.py` and `scripts/fabric-profiles.mjs`**:
|
|
36
|
+
- `resolve_model_profile` / `resolveModelProfile` (FAC-SEM-060). The order is request, agent, binding or project,
|
|
37
|
+
then estate default. The project's `modelClasses` narrow the result, and when nothing is left the launch is
|
|
38
|
+
refused as `model-classes-empty`, with the policy named.
|
|
39
|
+
- `profile_problems` / `profileProblems`, the FAC-SEM-058…060 checks of catalogues, profile sets, runner profiles
|
|
40
|
+
and recorded resolutions.
|
|
41
|
+
- `runner_check` / `runnerCheck`, for the doctor (runners.md C2). It runs only the catalogued bare binary on `PATH`
|
|
42
|
+
with its catalogued version and `auth` argv, never through a shell, for at most 5 s.
|
|
43
|
+
- `model_usable`.
|
|
44
|
+
- `configuration`, the well-known `configuration`, and `configuration_summary`, which gives the Runner and Model
|
|
45
|
+
tiles for the dashboard.
|
|
46
|
+
- **`scripts/fabric_secrets.py` and `scripts/fabric-secrets.mjs`**: the contract's one credential detector
|
|
47
|
+
(`secret-shape.ts`), with `credential_name`, `secret_value`, `secret_argument` and `secret_url`. It is the base for
|
|
48
|
+
FAA-23 (LC-21).
|
|
49
|
+
- **`build_well_known(configuration=)` / `buildWellKnown({ configuration })`** validates the configuration and carries
|
|
50
|
+
it. `configuration_problems` / `configurationProblems` are added.
|
|
51
|
+
- **`make_usage_receipt(route=)` / `makeUsageReceipt({ route })`**: a receipt records `subscription` or `key`, and
|
|
52
|
+
the report keeps the rows of one model on two routes apart.
|
|
53
|
+
- **Probe rule `configuration.declared`.**
|
|
54
|
+
- **Vectors**: `test/fixtures/contract/profile-vectors.json` holds 17 rule cases, 35 resolutions and 128
|
|
55
|
+
credential-detector cases. Every expected answer comes from the contract's own `profile-rules.ts` and
|
|
56
|
+
`secret-shape.ts` (`make_profile_vectors.mjs`). Credential-shaped samples are made up and stored hex-encoded.
|
|
57
|
+
- Tests: 11 planted defects across both kits were each caught.
|
|
58
|
+
- **Docs**: `references/profiles.md`, a pointer in SKILL.md, and an updated `protocol.md`. The scenario eval
|
|
59
|
+
`runner-and-model-profiles` came first.
|
|
60
|
+
|
|
3
61
|
## 0.15.0 - 2026-10-12
|
|
4
62
|
|
|
5
63
|
Fabric's button in the dashboard kit (contract DEC-0040, FB-7). With it, FAA-20 is complete. The contract pin is
|
package/README.md
CHANGED
|
@@ -230,8 +230,8 @@ FABRIC_CONTRACT_OLD=/path/to/legacy-contract FABRIC_CONTRACT_NEW=/path/to/curren
|
|
|
230
230
|
```
|
|
231
231
|
|
|
232
232
|
Without both environment variables, those tests explicitly skip as `NOT_RUN`.
|
|
233
|
-
This source checkout is 0.
|
|
234
|
-
were never released); 0.
|
|
233
|
+
This source checkout is 0.17.0. 0.16.0 is the latest published GitHub/npm release (2026-10-12; 0.7.0 and 0.11.0
|
|
234
|
+
were never released); 0.17.0 is published by its `v0.17.0` tag. Source delivery does not update installed skills or live sessions.
|
|
235
235
|
|
|
236
236
|
## License
|
|
237
237
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
|
|
@@ -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.
|
|
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.
|
|
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
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md
CHANGED
|
@@ -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
|
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py
CHANGED
|
@@ -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 {
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py
CHANGED
|
@@ -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"]
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/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
|
|
@@ -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.
|
|
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
|
-
|
|
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`
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md
CHANGED
|
@@ -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
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -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."""
|