@passioncode-ai/fabric-agent-adapter 0.15.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 CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0 - 2026-10-12
4
+
5
+ Runner and model profiles in both kits (contract DEC-0046, Fabric ADR-0147; FAA-19). The contract's credential
6
+ detector (DEC-0044) is ported too. The contract pin is unchanged (`0cacd08`).
7
+
8
+ ### Added
9
+
10
+ - **`scripts/fabric_profiles.py` and `scripts/fabric-profiles.mjs`**:
11
+ - `resolve_model_profile` / `resolveModelProfile` (FAC-SEM-060). The order is request, agent, binding or project,
12
+ then estate default. The project's `modelClasses` narrow the result, and when nothing is left the launch is
13
+ refused as `model-classes-empty`, with the policy named.
14
+ - `profile_problems` / `profileProblems`, the FAC-SEM-058…060 checks of catalogues, profile sets, runner profiles
15
+ and recorded resolutions.
16
+ - `runner_check` / `runnerCheck`, for the doctor (runners.md C2). It runs only the catalogued bare binary on `PATH`
17
+ with its catalogued version and `auth` argv, never through a shell, for at most 5 s.
18
+ - `model_usable`.
19
+ - `configuration`, the well-known `configuration`, and `configuration_summary`, which gives the Runner and Model
20
+ tiles for the dashboard.
21
+ - **`scripts/fabric_secrets.py` and `scripts/fabric-secrets.mjs`**: the contract's one credential detector
22
+ (`secret-shape.ts`), with `credential_name`, `secret_value`, `secret_argument` and `secret_url`. It is the base for
23
+ FAA-23 (LC-21).
24
+ - **`build_well_known(configuration=)` / `buildWellKnown({ configuration })`** validates the configuration and carries
25
+ it. `configuration_problems` / `configurationProblems` are added.
26
+ - **`make_usage_receipt(route=)` / `makeUsageReceipt({ route })`**: a receipt records `subscription` or `key`, and
27
+ the report keeps the rows of one model on two routes apart.
28
+ - **Probe rule `configuration.declared`.**
29
+ - **Vectors**: `test/fixtures/contract/profile-vectors.json` holds 17 rule cases, 35 resolutions and 128
30
+ credential-detector cases. Every expected answer comes from the contract's own `profile-rules.ts` and
31
+ `secret-shape.ts` (`make_profile_vectors.mjs`). Credential-shaped samples are made up and stored hex-encoded.
32
+ - Tests: 11 planted defects across both kits were each caught.
33
+ - **Docs**: `references/profiles.md`, a pointer in SKILL.md, and an updated `protocol.md`. The scenario eval
34
+ `runner-and-model-profiles` came first.
35
+
3
36
  ## 0.15.0 - 2026-10-12
4
37
 
5
38
  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.15.0. 0.14.0 is the latest published GitHub/npm release (2026-10-12; 0.7.0 and 0.11.0
234
- were never released); 0.15.0 is published by its `v0.15.0` tag. Source delivery does not update installed skills or live sessions.
233
+ This source checkout is 0.16.0. 0.15.0 is the latest published GitHub/npm release (2026-10-12; 0.7.0 and 0.11.0
234
+ were never released); 0.16.0 is published by its `v0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
6
+ "version": "0.16.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.16.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "0cacd08b0e95b6abf131eae1206b60792138bc3c"
11
11
  ---
@@ -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.16.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`
@@ -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."""
@@ -0,0 +1,196 @@
1
+ // Runner and model profiles in the kit (Fabric Agent Contract DEC-0046, Fabric ADR-0147). A port of the contract's
2
+ // `src/profile-rules.ts` at the pin — resolveModelProfile and the FAC-SEM-058…060 checks — held to the contract's own
3
+ // answers by test/fixtures/contract/profile-vectors.json; plus the doctor's runner and model checks (runners.md C2) and
4
+ // the well-known `configuration` with its summary tiles ("Runner & model"). No dependencies.
5
+
6
+ import { spawnSync } from 'node:child_process';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { secretValue } from './fabric-secrets.mjs';
10
+ import { configurationProblems } from './fabric-service.mjs';
11
+
12
+ export const RESOLUTION_ORDER = Object.freeze(['request', 'agent', 'binding', 'estate']);
13
+ /** The vendor login each subscription is, and the one runner kind it runs in (DEC-0046, ADR-0147 decision 2). */
14
+ export const SUBSCRIPTION_RUNNER = Object.freeze({ claude: 'claude-code', kimi: 'kimi-code', chatgpt: 'codex' });
15
+ export const MAX_PROBE_MS = 5000;
16
+ const BARE_BINARY = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
17
+
18
+ export class ProfileError extends Error {}
19
+
20
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
21
+ const list = (value) => (Array.isArray(value) ? value.filter(isObject) : []);
22
+ const strings = (value) => (Array.isArray(value) ? value.filter((item) => typeof item === 'string') : []);
23
+ const jsonEqual = (a, b) => JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b));
24
+ function sortKeys(value) {
25
+ if (Array.isArray(value)) return value.map(sortKeys);
26
+ if (isObject(value)) return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortKeys(value[k])]));
27
+ return value;
28
+ }
29
+
30
+ /** FAC-SEM-060: the request's profile, else the agent's, else the binding's or project's, else the estate default. */
31
+ export function resolveModelProfile({ catalogue, profiles, choices = {}, policy } = {}) {
32
+ const level = RESOLUTION_ORDER.find((name) => typeof choices?.[name] === 'string');
33
+ if (level === undefined) return { refused: 'no-profile' };
34
+ const id = choices[level];
35
+ const profile = list(profiles).find((item) => item.id === id);
36
+ if (!profile) return { refused: 'unknown-profile', profile: id };
37
+ const models = strings(profile.models);
38
+ if (!isObject(policy) || !Array.isArray(policy.modelClasses)) return { from: level, profile: id, models };
39
+ const allowed = new Set(strings(policy.modelClasses));
40
+ const rows = new Map(list(isObject(catalogue) ? catalogue.models : undefined).map((row) => [row.id, row]));
41
+ const within = models.filter((model) => { const row = rows.get(model); return row !== undefined && strings(row.classes).every((name) => allowed.has(name)); });
42
+ if (within.length) return { from: level, profile: id, models: within };
43
+ const meta = isObject(policy.meta) ? policy.meta : {};
44
+ return { refused: 'model-classes-empty', profile: id, policy: { id: String(meta.id), revision: Number(meta.revision) } };
45
+ }
46
+
47
+ const finding = (code, instancePath, message) => ({ code, instancePath, message });
48
+
49
+ function modelCatalogue(value) {
50
+ const findings = [];
51
+ const seen = new Set();
52
+ list(value.models).forEach((row, index) => {
53
+ if (seen.has(row.id)) findings.push(finding('FAC-SEM-058', `/models/${index}/id`, `model ${String(row.id)} is catalogued more than once`));
54
+ seen.add(row.id);
55
+ const routing = isObject(row.provider) ? row.provider : {};
56
+ const ignored = new Set(strings(routing.ignore));
57
+ for (const slug of strings(routing.order)) {
58
+ if (ignored.has(slug) || ignored.has(slug.split('/')[0])) findings.push(finding('FAC-SEM-058', `/models/${index}/provider`, `model ${String(row.id)} both orders and ignores provider ${slug}`));
59
+ }
60
+ });
61
+ return findings;
62
+ }
63
+
64
+ function modelProfiles(value) {
65
+ const findings = [];
66
+ const seen = new Set();
67
+ list(value.profiles).forEach((profile, index) => {
68
+ if (seen.has(profile.id)) findings.push(finding('FAC-SEM-058', `/profiles/${index}/id`, `model profile ${String(profile.id)} is declared more than once`));
69
+ seen.add(profile.id);
70
+ });
71
+ return findings;
72
+ }
73
+
74
+ function runnerProfile(value) {
75
+ const findings = [];
76
+ if (value.route === 'subscription' && isObject(value.subscription)) {
77
+ const login = String(value.subscription.login);
78
+ const runner = Object.hasOwn(SUBSCRIPTION_RUNNER, login) ? SUBSCRIPTION_RUNNER[login] : undefined;
79
+ if (runner !== value.runnerKind) findings.push(finding('FAC-SEM-059', '/runnerKind', `a ${login} subscription runs only in ${runner ?? "its vendor's runner"}, not in ${String(value.runnerKind)}; another runner takes the key route`));
80
+ }
81
+ if (value.route === 'key' && isObject(value.key)) {
82
+ if (value.key.modelAllowList !== value.modelProfile) findings.push(finding('FAC-SEM-059', '/key/modelAllowList', `the key admits the models of ${String(value.key.modelAllowList)}, but the profile runs ${String(value.modelProfile)}`));
83
+ if (typeof value.key.ref === 'string' && secretValue(value.key.ref)) findings.push(finding('FAC-SEM-059', '/key/ref', 'the key is named by its secret reference, never by its value'));
84
+ }
85
+ return findings;
86
+ }
87
+
88
+ function profileResolution(value) {
89
+ const findings = [];
90
+ const catalogued = new Set(list(isObject(value.catalogue) ? value.catalogue.models : undefined).map((row) => row.id));
91
+ const ids = new Set();
92
+ list(value.profiles).forEach((profile, i) => {
93
+ if (ids.has(profile.id)) findings.push(finding('FAC-SEM-060', `/profiles/${i}/id`, `profile id ${String(profile.id)} is offered more than once to one resolution`));
94
+ ids.add(profile.id);
95
+ strings(profile.models).forEach((model, j) => {
96
+ if (!catalogued.has(model)) findings.push(finding('FAC-SEM-060', `/profiles/${i}/models/${j}`, `profile ${String(profile.id)} names ${model}, which the catalogue does not hold`));
97
+ });
98
+ });
99
+ const choices = isObject(value.choices) ? value.choices : {};
100
+ const expected = resolveModelProfile({ catalogue: value.catalogue, profiles: value.profiles, choices, policy: value.policy });
101
+ if (value.resolved === undefined || !jsonEqual(value.resolved, expected)) findings.push(finding('FAC-SEM-060', '/resolved', `the resolution is ${JSON.stringify(expected)}`));
102
+ return findings;
103
+ }
104
+
105
+ /** The contract's profileRules: findings for a model-catalogue, model-profiles, runner-profile or profile-resolution; else undefined. */
106
+ export function profileProblems(kind, value) {
107
+ return { 'model-catalogue': modelCatalogue, 'model-profiles': modelProfiles, 'runner-profile': runnerProfile, 'profile-resolution': profileResolution }[kind]?.(value);
108
+ }
109
+
110
+ // ---- the doctor's checks ----
111
+
112
+ const argvOf = (value, label) => {
113
+ if (!Array.isArray(value) || !value.every((a) => typeof a === 'string')) throw new ProfileError(`${label} is an argument array, never a shell string`);
114
+ return [...value];
115
+ };
116
+
117
+ function which(name, searchPath) {
118
+ for (const dir of String(searchPath ?? '').split(path.delimiter).filter(Boolean)) {
119
+ const candidate = path.join(dir, name);
120
+ try { fs.accessSync(candidate, fs.constants.X_OK); if (fs.statSync(candidate).isFile()) return candidate; } catch { /* next */ }
121
+ }
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * runners.md C2: is the catalogued runner installed, which version answers, and is it signed in. Only a bare
127
+ * catalogued binary on PATH is run, only with the catalogued version (and auth) argv, without a shell, within its
128
+ * timeout (at most 5 s). An entry outside these rules is refused and nothing runs.
129
+ */
130
+ export function runnerCheck(entry, { searchPath = process.env.PATH, run = spawnSync } = {}) {
131
+ const { kind, binaries } = entry ?? {};
132
+ if (!Array.isArray(binaries) || !binaries.length || !binaries.every((b) => typeof b === 'string' && BARE_BINARY.test(b) && !b.includes('..'))) throw new ProfileError('binaries are bare executable names, never paths');
133
+ const version = isObject(entry.version) ? entry.version : {};
134
+ const versionArgv = argvOf(version.argv, 'version.argv');
135
+ if (typeof version.pattern !== 'string') throw new ProfileError('version.pattern is a regular expression');
136
+ const timeoutMs = version.timeoutMs ?? MAX_PROBE_MS;
137
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_PROBE_MS) throw new ProfileError(`version.timeoutMs is at most ${MAX_PROBE_MS}`);
138
+ const authArgv = 'auth' in entry ? argvOf(entry.auth, 'auth') : null;
139
+ const out = { kind, installed: false, binary: null, version: null, signedIn: null, problems: [] };
140
+ const binary = binaries.map((name) => which(name, searchPath)).find(Boolean) ?? null;
141
+ if (!binary) { out.problems.push(`${kind} is not installed: none of ${binaries.join(', ')} is on PATH`); return out; }
142
+ Object.assign(out, { installed: true, binary });
143
+ const probe = (argv) => {
144
+ const result = run(binary, argv, { encoding: 'utf8', timeout: timeoutMs, shell: false });
145
+ if (result.error?.code === 'ETIMEDOUT' || result.signal === 'SIGTERM') { out.problems.push(`${kind} ${argv.join(' ')} did not answer within ${timeoutMs} ms`); return null; }
146
+ if (result.error) { out.problems.push(`${kind} could not be run: ${result.error.code ?? result.error.name}`); return null; }
147
+ return result;
148
+ };
149
+ const answered = probe(versionArgv);
150
+ if (answered) {
151
+ const re = new RegExp(version.pattern);
152
+ const match = re.exec(answered.stdout ?? '') ?? re.exec(answered.stderr ?? '');
153
+ if (match && match.length > 1) out.version = match[1];
154
+ else out.problems.push(`${kind} answered, but its version did not match the catalogue's pattern`);
155
+ }
156
+ if (authArgv) {
157
+ const signed = probe(authArgv);
158
+ if (signed) { out.signedIn = signed.status === 0; if (!out.signedIn) out.problems.push(`${kind} is installed but not signed in`); }
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /** Problems with running `model` here: not in the catalogue, or a class the project's policy does not allow. */
164
+ export function modelUsable(model, catalogue, policy) {
165
+ const rows = new Map(list(isObject(catalogue) ? catalogue.models : undefined).map((row) => [row.id, row]));
166
+ if (!rows.has(model)) return [`model ${model} is not in the model catalogue`];
167
+ if (isObject(policy) && Array.isArray(policy.modelClasses)) {
168
+ const allowed = new Set(strings(policy.modelClasses));
169
+ const outside = strings(rows.get(model).classes).filter((c) => !allowed.has(c));
170
+ if (outside.length) return [`model ${model} has class ${outside.join(', ')}, which policy ${String((isObject(policy.meta) ? policy.meta : {}).id)} does not allow`];
171
+ }
172
+ return [];
173
+ }
174
+
175
+ // ---- the well-known `configuration` and its tiles ----
176
+
177
+ /** The well-known `configuration` (DEC-0046): read-only; a change is made in Fabric and reaches the agent through its binding. */
178
+ export function configuration(slots, resolution, runnerProfileDoc) {
179
+ if ('refused' in resolution) throw new ProfileError(`the resolution was refused (${resolution.refused}); a refused resolution is not a configuration`);
180
+ const resolved = { from: resolution.from };
181
+ if (runnerProfileDoc) Object.assign(resolved, { runnerProfile: runnerProfileDoc.id, runnerKind: runnerProfileDoc.runnerKind, route: runnerProfileDoc.route });
182
+ Object.assign(resolved, { modelProfile: resolution.profile, models: [...(resolution.models ?? [])] });
183
+ const doc = { slots: [...slots], resolved: Object.fromEntries(Object.entries(resolved).filter(([, v]) => v !== undefined && v !== null)) };
184
+ const problems = configurationProblems(doc);
185
+ if (problems.length) throw new ProfileError(problems.join('; '));
186
+ return doc;
187
+ }
188
+
189
+ /** Summary tiles for the well-known document and the dashboard — "Runner & model". */
190
+ export function configurationSummary(doc) {
191
+ const resolved = doc?.resolved ?? {};
192
+ const tiles = [];
193
+ if (resolved.runnerKind) tiles.push({ label: 'Runner', value: [resolved.runnerKind, resolved.route].filter(Boolean).join(' · ') });
194
+ if (resolved.modelProfile) tiles.push({ label: 'Model', value: [resolved.modelProfile, (resolved.models ?? [])[0]].filter(Boolean).join(' · ') });
195
+ return tiles;
196
+ }
@@ -0,0 +1,86 @@
1
+ // The contract's one credential detector (Fabric Agent Contract DEC-0044, `src/secret-shape.ts` at the pin): a name
2
+ // that can only carry a credential, a value shaped like one, a launch argument that carries one, and a URL that does
3
+ // (LC-21). A port held to the contract's own answers by test/fixtures/contract/profile-vectors.json. No dependencies.
4
+
5
+ /** A name's words: split at `.`, `_`, `-` and camelCase humps, lowercased (`APIKey` is `api key`). */
6
+ export function nameWords(name) {
7
+ return name
8
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
9
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
10
+ .split(/[\s._-]+/)
11
+ .filter(Boolean)
12
+ .map((word) => word.toLowerCase());
13
+ }
14
+
15
+ export const CREDENTIAL_WORDS = Object.freeze([
16
+ 'token', 'secret', 'password', 'passwd', 'pass', 'passphrase', 'credential', 'credentials', 'cookie', 'authorization',
17
+ 'bearer', 'jwt', 'otp', 'apikey', 'signature', 'sig',
18
+ 'accesstoken', 'idtoken', 'authtoken', 'refreshtoken', 'sessionid', 'sessiontoken', 'logincode', 'authcode', 'privatekey',
19
+ 'clientsecret', 'secretkey', 'accesskey',
20
+ ]);
21
+ export const CREDENTIAL_PAIRS = Object.freeze({
22
+ key: ['private', 'api', 'access', 'secret', 'session', 'auth', 'signing', 'encryption', 'master', 'client', 'login', 'refresh'],
23
+ auth: ['token', 'code', 'header'],
24
+ session: ['token', 'id'],
25
+ code: ['login', 'auth', 'access', 'session'],
26
+ });
27
+
28
+ const withSingulars = (words) => [...words, ...words.filter((word) => word.length > 2 && word.endsWith('s')).map((word) => word.slice(0, -1))];
29
+
30
+ /** Whether a name — a parameter, a header, an environment variable, a config key — can only carry a credential. */
31
+ export function credentialName(name) {
32
+ const split = nameWords(name);
33
+ const words = withSingulars(split);
34
+ if (words.some((word) => CREDENTIAL_WORDS.includes(word))) return true;
35
+ if (split.length === 1 && words.some((word) => Object.hasOwn(CREDENTIAL_PAIRS, word))) return true;
36
+ return Object.entries(CREDENTIAL_PAIRS).some(([word, partners]) => words.includes(word) && partners.some((partner) => words.includes(partner)));
37
+ }
38
+
39
+ /** A JWT or JWS anywhere in a value. */
40
+ export const JWT_SHAPE = /eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*/;
41
+ /** A value that says it is a credential, or a provider key's shape. */
42
+ export const SECRET_VALUE = /(^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,})/i;
43
+ const LONG_RUN = /[A-Za-z0-9+/=_-]{40,}/g;
44
+ const longSecret = (value) => (value.match(LONG_RUN) ?? []).some((run) => /[a-z]/.test(run) && /[A-Z]/.test(run) && /[0-9]/.test(run));
45
+
46
+ /** Whether a value could be a credential: a JWT anywhere, a provider key or `Bearer …`, or a long secret-shaped run. */
47
+ export const secretValue = (value) => JWT_SHAPE.test(value) || SECRET_VALUE.test(value) || longSecret(value);
48
+
49
+ const BEARER_INSIDE = /\bbearer\s+(?=[A-Za-z0-9._~+/-]*\d)[A-Za-z0-9._~+/-]{12,}=*/i;
50
+ const PATH_LIKE = /^(?:[/~.]|[A-Za-z]:[\\/]|[a-z][a-z0-9+.-]*:\/\/)/i;
51
+ const pathLike = (piece) =>
52
+ PATH_LIKE.test(piece) ||
53
+ (/[\\/]/.test(piece) && (/\.[A-Za-z0-9]{1,8}$/.test(piece) || piece.split(/[\\/]/).some((segment) => /^[a-z][a-z0-9_-]{2,}$/.test(segment))));
54
+
55
+ /** The same for a launch argument or a server's command and URL: a long run is looked for between path separators. */
56
+ export const secretArgument = (value) =>
57
+ JWT_SHAPE.test(value) || SECRET_VALUE.test(value) || BEARER_INSIDE.test(value) ||
58
+ value.split(/[\s=]/).some((piece) => (pathLike(piece) ? piece.split(/[\\/]/).some((part) => longSecret(part)) : longSecret(piece)));
59
+
60
+ const decodeKeepingPlus = (raw) => { try { return decodeURIComponent(raw); } catch { return raw; } };
61
+
62
+ /** Each query or fragment piece's raw value by its decoded name, with `+` kept. */
63
+ export function rawValues(query) {
64
+ const values = new Map();
65
+ for (const piece of query.split('&')) {
66
+ if (piece === '') continue;
67
+ const at = piece.indexOf('=');
68
+ const name = [...new URLSearchParams(at < 0 ? piece : piece.slice(0, at)).keys()][0] ?? '';
69
+ values.set(name, [...(values.get(name) ?? []), at < 0 ? '' : decodeKeepingPlus(piece.slice(at + 1))]);
70
+ }
71
+ return values;
72
+ }
73
+
74
+ /** Whether a URL an MCP entry or a launch names carries a credential (LC-21). */
75
+ export function secretUrl(value) {
76
+ if (secretArgument(value)) return true;
77
+ let url;
78
+ try { url = new URL(value); } catch { return false; }
79
+ if (url.password !== '') return true;
80
+ for (const part of [url.search, url.hash]) {
81
+ for (const [name, values] of rawValues(part.slice(1))) {
82
+ if (values.some((raw) => (raw !== '' && credentialName(name)) || secretValue(raw))) return true;
83
+ }
84
+ }
85
+ return false;
86
+ }
@@ -382,7 +382,34 @@ export function dashboardExtension({ minWidth = 360, appearance = ['scheme', 'th
382
382
 
383
383
  // `extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration in
384
384
  // it must be valid (dashboardExtension builds one).
385
- export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions }) {
385
+ const PROFILE_ID = /^[a-z][a-z0-9-]{0,62}$/;
386
+ const RUNNER_KIND = /^[a-z][a-z0-9-]{1,62}$/;
387
+ const MODEL_ID = /^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._:-]{0,127}$/;
388
+ export const ROUTES = Object.freeze(['subscription', 'key']);
389
+
390
+ /** DEC-0046: the well-known `configuration` — {slots, resolved} as service-well-known.schema.json types it. */
391
+ export function configurationProblems(doc) {
392
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) return ['configuration is an object'];
393
+ const problems = Object.keys(doc).filter((k) => k !== 'slots' && k !== 'resolved').map((k) => `configuration has no member ${k}`);
394
+ const { slots, resolved } = doc;
395
+ if (!Array.isArray(slots) || slots.length > 2 || new Set(slots).size !== slots.length || !slots.every((s) => s === 'runner' || s === 'model')) problems.push('slots is a list of runner and model, each once');
396
+ if (resolved === null || typeof resolved !== 'object' || Array.isArray(resolved)) return [...problems, 'resolved is an object'];
397
+ const checks = {
398
+ from: (x) => ['request', 'agent', 'binding', 'estate'].includes(x),
399
+ runnerProfile: (x) => typeof x === 'string' && PROFILE_ID.test(x),
400
+ modelProfile: (x) => typeof x === 'string' && PROFILE_ID.test(x),
401
+ runnerKind: (x) => typeof x === 'string' && RUNNER_KIND.test(x),
402
+ route: (x) => ROUTES.includes(x),
403
+ models: (x) => Array.isArray(x) && x.length <= 16 && x.every((m) => typeof m === 'string' && MODEL_ID.test(m)),
404
+ };
405
+ for (const [k, v] of Object.entries(resolved)) {
406
+ if (!Object.hasOwn(checks, k)) problems.push(`resolved has no member ${k}`);
407
+ else if (!checks[k](v)) problems.push(`resolved.${k} is not valid`);
408
+ }
409
+ return problems;
410
+ }
411
+
412
+ export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions, configuration }) {
386
413
  if (!STATUSES.includes(status)) throw new ServiceError(`status must be one of ${STATUSES.join(', ')}.`);
387
414
  if (!surfaces?.events) throw new ServiceError('surfaces.events is required.');
388
415
  if (!(build?.commit || build?.digest)) throw new ServiceError('build needs a commit or a digest.');
@@ -403,6 +430,11 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
403
430
  }
404
431
  doc.extensions = { ...extensions };
405
432
  }
433
+ if (configuration !== undefined) {
434
+ const problems = configurationProblems(configuration);
435
+ if (problems.length) throw new ServiceError(`configuration: ${problems.join('; ')}.`);
436
+ doc.configuration = configuration;
437
+ }
406
438
  return doc;
407
439
  }
408
440
 
@@ -450,7 +482,7 @@ const COUNTS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTok
450
482
  * One model call, from the provider's own usage numbers. No prompt, output or caller.
451
483
  * `costUsd` null means it cannot be priced: the report counts it as unpriced, never as $0.
452
484
  */
453
- export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, costUsd = null, costBasis, at } = {}) {
485
+ export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, costUsd = null, costBasis, at, route } = {}) {
454
486
  if (!PROVIDER.test(provider ?? '')) throw new ServiceError('provider must be a lowercase name such as anthropic or openrouter.');
455
487
  model = String(model ?? '').trim();
456
488
  if (!model || model.length > 128) throw new ServiceError('model must be 1 to 128 characters.');
@@ -459,7 +491,8 @@ export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, c
459
491
  if (costUsd !== null && (typeof costUsd !== 'number' || !Number.isFinite(costUsd) || costUsd < 0)) throw new ServiceError('costUsd must be a non-negative number or null.');
460
492
  const basis = costBasis ?? (costUsd === null ? 'unknown' : 'provider');
461
493
  if (!COST_BASES.includes(basis) || (costUsd === null) !== (basis === 'unknown')) throw new ServiceError('costBasis is provider or price-list with a cost, unknown without one.');
462
- return { at: at ?? nowIso(), provider, model, ...counts, costUsd, costBasis: basis };
494
+ if (route !== undefined && !ROUTES.includes(route)) throw new ServiceError('route is subscription or key (DEC-0046).');
495
+ return { at: at ?? nowIso(), provider, model, ...counts, costUsd, costBasis: basis, ...(route === undefined ? {} : { route }) };
463
496
  }
464
497
 
465
498
  const utcDate = (ms) => new Date(ms).toISOString().slice(0, 10);
@@ -482,8 +515,9 @@ export function usageReport(receipts, { id, instance = 'default', now = Date.now
482
515
  if (date < first || date > last) continue;
483
516
  const rows = days.get(date) ?? new Map();
484
517
  days.set(date, rows);
485
- const k = `${r.provider}\u0000${r.model}`;
486
- const row = rows.get(k) ?? emptyRow({ provider: r.provider, model: r.model, bases: new Set() });
518
+ const k = `${r.provider}\u0000${r.model}\u0000${r.route ?? ''}`;
519
+ // DEC-0046: rows of one model on two routes stay apart.
520
+ const row = rows.get(k) ?? emptyRow({ provider: r.provider, model: r.model, ...(r.route ? { route: r.route } : {}), bases: new Set() });
487
521
  rows.set(k, row);
488
522
  row.calls += 1;
489
523
  for (const c of COUNTS) row[c] += Number(r[c] ?? 0);
@@ -493,7 +527,7 @@ export function usageReport(receipts, { id, instance = 'default', now = Date.now
493
527
  const outDays = [...days.keys()].sort().map((date) => {
494
528
  const models = [...days.get(date).values()]
495
529
  .map(({ bases, ...row }) => closeRow({ ...row, costBasis: bases.size === 1 ? [...bases][0] : 'mixed' }))
496
- .sort((a, b) => (a.provider + a.model < b.provider + b.model ? -1 : 1));
530
+ .sort((a, b) => (`${a.provider}\u0000${a.model}\u0000${a.route ?? ''}` < `${b.provider}\u0000${b.model}\u0000${b.route ?? ''}` ? -1 : 1));
497
531
  const day = emptyRow({ date });
498
532
  for (const m of models) {
499
533
  for (const c of ['calls', 'unpricedCalls', ...COUNTS]) day[c] += m[c];
@@ -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
@@ -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.16.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "0cacd08b0e95b6abf131eae1206b60792138bc3c"
11
11
  ---