@passioncode-ai/fabric-agent-adapter 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/demo.html +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +280 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +30 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +31 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +64 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +196 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +86 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +41 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +277 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +132 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +54 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
|
@@ -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/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."""
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py
CHANGED
|
@@ -27,7 +27,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
|
27
27
|
|
|
28
28
|
KIT_DIR = Path(__file__).resolve().parent.parent / "assets" / "dashboard-kit"
|
|
29
29
|
KIT_NAME = "fabric-dashboard-kit"
|
|
30
|
-
COPIED = ("fabric-dashboard.css", "fabric-dashboard.js")
|
|
30
|
+
COPIED = ("fabric-dashboard.css", "fabric-dashboard.js", "fabric-button.js") # fabric-button.js: DEC-0040, FB-7
|
|
31
31
|
MARKER = re.compile(r"fabric-dashboard-kit@(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?")
|
|
32
32
|
CSS_BEGIN, CSS_END = "/* fabric-dashboard-kit:themes:begin */", "/* fabric-dashboard-kit:themes:end */"
|
|
33
33
|
JS_BEGIN, JS_END = "/* fabric-dashboard-kit:theme-ids:begin */", "/* fabric-dashboard-kit:theme-ids:end */"
|
|
@@ -125,7 +125,10 @@ def build(kit_dir: Path = KIT_DIR, check: bool = False) -> List[str]:
|
|
|
125
125
|
new_js = replace_between(js, JS_BEGIN, JS_END, " var THEMES = %s;" % json.dumps(ids).replace('"', "'"), js_path.name)
|
|
126
126
|
manifest = load_manifest(kit_dir)
|
|
127
127
|
version = manifest["version"]
|
|
128
|
-
|
|
128
|
+
built = {css_path.name: new_css, js_path.name: new_js}
|
|
129
|
+
for name in COPIED:
|
|
130
|
+
built.setdefault(name, (kit_dir / name).read_text(encoding="utf-8"))
|
|
131
|
+
for name, text in built.items():
|
|
129
132
|
if claimed_version_text(text) != version:
|
|
130
133
|
raise KitError("%s says %s, kit.json says %s; raise them together." % (name, claimed_version_text(text), version))
|
|
131
134
|
import hashlib
|
|
@@ -135,7 +138,7 @@ def build(kit_dir: Path = KIT_DIR, check: bool = False) -> List[str]:
|
|
|
135
138
|
changed.append(path.name)
|
|
136
139
|
if not check:
|
|
137
140
|
atomic_write(path, text)
|
|
138
|
-
digest = {name: hashlib.sha256(
|
|
141
|
+
digest = {name: hashlib.sha256(built[name].encode("utf-8")).hexdigest() for name in COPIED}
|
|
139
142
|
digest["themes.json"] = sha256(kit_dir / "themes.json")
|
|
140
143
|
new_manifest = dict(manifest)
|
|
141
144
|
new_manifest["themes"] = ids
|
|
@@ -199,7 +202,7 @@ def repo_root(start: Path) -> Optional[Path]:
|
|
|
199
202
|
|
|
200
203
|
def find_copies(repo: Path) -> List[Path]:
|
|
201
204
|
listed = git(repo, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--",
|
|
202
|
-
"*fabric-dashboard.css", "*fabric-dashboard.js")
|
|
205
|
+
"*fabric-dashboard.css", "*fabric-dashboard.js", "*fabric-button.js")
|
|
203
206
|
if listed is not None:
|
|
204
207
|
paths = [repo / p for p in listed.split("\0") if p]
|
|
205
208
|
else:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs
ADDED
|
@@ -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
|
+
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs
ADDED
|
@@ -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
|
+
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -14,7 +14,7 @@ export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/exten
|
|
|
14
14
|
// The embedded dashboard declaration (fabric-dashboard/0.1, DEC-0036 ED-9), under the well-known
|
|
15
15
|
// document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
|
|
16
16
|
export const DASHBOARD_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1';
|
|
17
|
-
export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.
|
|
17
|
+
export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.0';
|
|
18
18
|
export const REFERENCE_THEMES = ['graphite', 'fabric', 'paper', 'forest', 'ocean', 'ember', 'contrast'];
|
|
19
19
|
export const EXIT_ALREADY_RUNNING = 75;
|
|
20
20
|
// Lifecycle contract (fabric-workspace knowledge/lifecycle.md), the same values as fabric_service.py.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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];
|