@passioncode-ai/fabric-agent-adapter 0.23.0 → 0.25.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 +82 -0
- package/README.md +1 -1
- 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 +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +17 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_identifiers.py +182 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +7 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +19 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +19 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +19 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-identifiers.mjs +156 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +9 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +152 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +11 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py +182 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +11 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +204 -15
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +2 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-identifiers.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Identifier equality (contract DEC-0076): two identifiers are the same when their normal forms are equal. A port of the
|
|
2
|
+
// contract's src/identifiers.ts (types removed), held to its fixtures/vectors/identifiers.json; the twin of
|
|
3
|
+
// fabric_identifiers.py. Nothing here throws.
|
|
4
|
+
|
|
5
|
+
const UNRESERVED = "A-Za-z0-9\\-._~";
|
|
6
|
+
const SUB_DELIMS = "!$&'()*+,;=";
|
|
7
|
+
const PCT = "%[0-9A-Fa-f]{2}";
|
|
8
|
+
/** `*( <chars> / pct-encoded )` — each a single character class beside one fixed triplet, so the match is linear. */
|
|
9
|
+
const run = (chars) => new RegExp(`^(?:[${chars}]|${PCT})*$`);
|
|
10
|
+
const USERINFO = run(`${UNRESERVED}${SUB_DELIMS}:`);
|
|
11
|
+
const REG_NAME = run(`${UNRESERVED}${SUB_DELIMS}`);
|
|
12
|
+
const PATH = run(`${UNRESERVED}${SUB_DELIMS}:@/`);
|
|
13
|
+
const QUERY = run(`${UNRESERVED}${SUB_DELIMS}:@/?`);
|
|
14
|
+
const PORT = /^[0-9]*$/;
|
|
15
|
+
const SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*$/;
|
|
16
|
+
|
|
17
|
+
// RFC 3986 §3.2.2: IP-literal = "[" ( IPv6address / IPvFuture ) "]".
|
|
18
|
+
const H16 = "[0-9A-Fa-f]{1,4}";
|
|
19
|
+
const DEC_OCTET = "(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])";
|
|
20
|
+
const IPV4 = `${DEC_OCTET}(?:\\.${DEC_OCTET}){3}`;
|
|
21
|
+
const LS32 = `(?:${H16}:${H16}|${IPV4})`;
|
|
22
|
+
const IPV6 = [
|
|
23
|
+
`(?:${H16}:){6}${LS32}`,
|
|
24
|
+
`::(?:${H16}:){5}${LS32}`,
|
|
25
|
+
`(?:${H16})?::(?:${H16}:){4}${LS32}`,
|
|
26
|
+
`(?:(?:${H16}:){0,1}${H16})?::(?:${H16}:){3}${LS32}`,
|
|
27
|
+
`(?:(?:${H16}:){0,2}${H16})?::(?:${H16}:){2}${LS32}`,
|
|
28
|
+
`(?:(?:${H16}:){0,3}${H16})?::${H16}:${LS32}`,
|
|
29
|
+
`(?:(?:${H16}:){0,4}${H16})?::${LS32}`,
|
|
30
|
+
`(?:(?:${H16}:){0,5}${H16})?::${H16}`,
|
|
31
|
+
`(?:(?:${H16}:){0,6}${H16})?::`,
|
|
32
|
+
].join("|");
|
|
33
|
+
const IP_LITERAL = new RegExp(`^\\[(?:${IPV6}|[Vv][0-9A-Fa-f]+\\.[${UNRESERVED}${SUB_DELIMS}:]+)\\]$`);
|
|
34
|
+
|
|
35
|
+
// RFC 8141 §2: NID = (alphanum) 0*30(ldh) (alphanum), then ":" and a non-empty NSS.
|
|
36
|
+
const URN_NAME = /^([A-Za-z0-9][A-Za-z0-9-]{0,30}[A-Za-z0-9]):(.+)$/s;
|
|
37
|
+
|
|
38
|
+
/** Percent-encoding hex upper-cased (§6.2.2.1), and unreserved characters decoded unless `keepEncoded` (§6.2.2.2). */
|
|
39
|
+
function percent(text, keepEncoded = false) {
|
|
40
|
+
return text.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => {
|
|
41
|
+
const char = String.fromCharCode(Number.parseInt(hex, 16));
|
|
42
|
+
return !keepEncoded && /^[A-Za-z0-9\-._~]$/.test(char) ? char : `%${hex.toUpperCase()}`;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A rootless path (`agent:./x`) loses its leading "./" and "../" segments — §5.2.4's step A — while what is left stays
|
|
48
|
+
* rootless: not empty and not beginning with "/". Dot segments further in are kept, because removing them by §5.2.4 can
|
|
49
|
+
* turn a rootless path into an absolute one (`a/../b` → `/b`). That narrowing is the contract's own, under RFC 3986
|
|
50
|
+
* §6.1's aim of no false positives (DEC-0076).
|
|
51
|
+
*/
|
|
52
|
+
function stripLeadingDots(path) {
|
|
53
|
+
let start = 0;
|
|
54
|
+
while (path.startsWith("./", start) || path.startsWith("../", start)) start += path.startsWith("./", start) ? 2 : 3;
|
|
55
|
+
const rest = path.slice(start);
|
|
56
|
+
return rest === "" || rest.startsWith("/") ? path : rest;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* RFC 3986 §5.2.4 remove_dot_segments, over a path whose `.` and `..` are already decoded. Its input buffer is read
|
|
61
|
+
* segment by segment: the path split on "/" gives the same steps without copying the rest of the input at each one.
|
|
62
|
+
*/
|
|
63
|
+
function removeDotSegments(path) {
|
|
64
|
+
const absolute = path.startsWith("/");
|
|
65
|
+
const segments = (absolute ? path.slice(1) : path).split("/");
|
|
66
|
+
const output = [];
|
|
67
|
+
segments.forEach((segment, index) => {
|
|
68
|
+
const last = index === segments.length - 1;
|
|
69
|
+
if (segment === "." || segment === "..") {
|
|
70
|
+
if (segment === ".." && output.length > 0) output.pop();
|
|
71
|
+
// A dot segment that ends the path leaves the path ending in "/" (§5.2.4 steps B and C on "/." and "/..").
|
|
72
|
+
if (last) output.push("");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
output.push(segment);
|
|
76
|
+
});
|
|
77
|
+
return (absolute ? "/" : "") + output.join("/");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
/** RFC 3986 Appendix B's split, then each component checked against its §3 production; undefined when one fails. */
|
|
82
|
+
function parse(text) {
|
|
83
|
+
const match = /^([^:/?#]+):(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/s.exec(text);
|
|
84
|
+
if (!match) return undefined;
|
|
85
|
+
const [, scheme, authority, path, query, fragment] = match;
|
|
86
|
+
if (!SCHEME.test(scheme) || !PATH.test(path)) return undefined;
|
|
87
|
+
if (query !== undefined && !QUERY.test(query)) return undefined;
|
|
88
|
+
if (fragment !== undefined && !QUERY.test(fragment)) return undefined;
|
|
89
|
+
if (authority !== undefined && !authorityParts(authority)) return undefined;
|
|
90
|
+
// An empty path with no authority (`x:`, RFC 3986's path-empty) is left as it is: the schema's `format: uri` refuses
|
|
91
|
+
// it, so no admitted value has it. (A path never begins with "//" here: Appendix B's split reads that as an authority.)
|
|
92
|
+
if (authority === undefined && path === "") return undefined;
|
|
93
|
+
return { scheme, authority, path, query, fragment };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function authorityParts(authority) {
|
|
97
|
+
const at = authority.lastIndexOf("@");
|
|
98
|
+
const userinfo = at === -1 ? undefined : authority.slice(0, at);
|
|
99
|
+
const hostPort = at === -1 ? authority : authority.slice(at + 1);
|
|
100
|
+
const bracket = hostPort.startsWith("[") ? hostPort.indexOf("]") : -1;
|
|
101
|
+
const colon = hostPort.indexOf(":", bracket === -1 ? 0 : bracket);
|
|
102
|
+
const host = colon === -1 ? hostPort : hostPort.slice(0, colon);
|
|
103
|
+
const port = colon === -1 ? undefined : hostPort.slice(colon + 1);
|
|
104
|
+
if (userinfo !== undefined && !USERINFO.test(userinfo)) return undefined;
|
|
105
|
+
if (port !== undefined && !PORT.test(port)) return undefined;
|
|
106
|
+
if (host.startsWith("[") ? !IP_LITERAL.test(host) : !REG_NAME.test(host)) return undefined;
|
|
107
|
+
return { userinfo, host, port };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** RFC 8141 §3.1: `urn` and the NID lower-cased, hex upper-cased, nothing decoded; r-, q- and f-components ignored. */
|
|
111
|
+
function urnForm(parts) {
|
|
112
|
+
const name = URN_NAME.exec(parts.path);
|
|
113
|
+
const path = name ? `${name[1].toLowerCase()}:${percent(name[2], true)}` : percent(parts.path, true);
|
|
114
|
+
// A "?" not followed by "+" or "=" is no URN component (RFC 8141 §2): it is kept, never dropped unread.
|
|
115
|
+
const query = parts.query !== undefined && !/^[+=]/.test(parts.query) ? `?${percent(parts.query, true)}` : "";
|
|
116
|
+
const authority = parts.authority !== undefined ? `//${percent(parts.authority, true)}` : "";
|
|
117
|
+
return `urn:${authority}${path}${query}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function genericForm(parts) {
|
|
121
|
+
let authority = "";
|
|
122
|
+
if (parts.authority !== undefined) {
|
|
123
|
+
const { userinfo, host, port } = authorityParts(parts.authority);
|
|
124
|
+
// The host is case-insensitive (§3.2.2); its remaining percent triplets keep upper-case hex.
|
|
125
|
+
const lowered = percent(host).toLowerCase().replace(/%[0-9a-f]{2}/g, (triplet) => triplet.toUpperCase());
|
|
126
|
+
authority = `//${userinfo !== undefined ? `${percent(userinfo)}@` : ""}${lowered}${port !== undefined ? `:${port}` : ""}`;
|
|
127
|
+
}
|
|
128
|
+
const decoded = percent(parts.path);
|
|
129
|
+
const hierarchical = parts.authority !== undefined || decoded.startsWith("/");
|
|
130
|
+
let path = hierarchical ? removeDotSegments(decoded) : stripLeadingDots(decoded);
|
|
131
|
+
// A path left starting with "//" and no authority would read as one: keep the decoded path instead.
|
|
132
|
+
if (parts.authority === undefined && path.startsWith("//")) path = decoded;
|
|
133
|
+
const query = parts.query !== undefined ? `?${percent(parts.query)}` : "";
|
|
134
|
+
const fragment = parts.fragment !== undefined ? `#${percent(parts.fragment)}` : "";
|
|
135
|
+
return `${parts.scheme.toLowerCase()}:${authority}${path}${query}${fragment}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The normal form of an identifier: a URI's syntax-normalized form (RFC 3986 §6.2.2; RFC 8141 §3.1 for `urn:`), any
|
|
140
|
+
* other string as it is, and undefined for a value that is not a string.
|
|
141
|
+
*/
|
|
142
|
+
export function normalIdentifier(value) {
|
|
143
|
+
if (typeof value !== "string") return undefined;
|
|
144
|
+
const parts = parse(value);
|
|
145
|
+
if (!parts) return value;
|
|
146
|
+
return parts.scheme.toLowerCase() === "urn" ? urnForm(parts) : genericForm(parts);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Two identifiers are the same when their normal forms are equal (DEC-0076). A value that is not a string is no
|
|
151
|
+
* identifier and is compared as it is (`===`), so a rule reads a schema-invalid member exactly as it did before.
|
|
152
|
+
*/
|
|
153
|
+
export function sameIdentifier(a, b) {
|
|
154
|
+
if (typeof a !== "string" || typeof b !== "string") return a === b;
|
|
155
|
+
return normalIdentifier(a) === normalIdentifier(b);
|
|
156
|
+
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// the well-known `configuration` with its summary tiles ("Runner & model"). No dependencies.
|
|
5
5
|
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { normalIdentifier, sameIdentifier } from './fabric-identifiers.mjs';
|
|
7
8
|
import fs from 'node:fs';
|
|
8
9
|
import path from 'node:path';
|
|
9
10
|
import { secretValue } from './fabric-secrets.mjs';
|
|
@@ -210,7 +211,8 @@ export const PROBE_RESULTS = Object.freeze(['not-catalogued', 'not-admitted', 'n
|
|
|
210
211
|
|
|
211
212
|
const routeCandidates = (value) => (Array.isArray(value?.candidates) ? value.candidates.filter(isObject) : []);
|
|
212
213
|
const objectAt = (value, key) => (isObject(value?.[key]) ? value[key] : {});
|
|
213
|
-
|
|
214
|
+
// A revision reference compared whole; its id is an identifier, so it enters the key in its normal form (DEC-0076).
|
|
215
|
+
const refKey = (ref) => (isObject(ref) ? `${String(normalIdentifier(ref.id) ?? ref.id)}@${String(ref.revision)}#${String(ref.contentHash)}` : '');
|
|
214
216
|
const sameRef = (a, b) => refKey(a) !== '' && refKey(a) === refKey(b);
|
|
215
217
|
|
|
216
218
|
function runnerRoute(value) {
|
|
@@ -229,7 +231,7 @@ function runnerRoute(value) {
|
|
|
229
231
|
function routeBundle(value) {
|
|
230
232
|
const route = objectAt(value, 'route');
|
|
231
233
|
const admissions = Array.isArray(value.admissions) ? value.admissions.filter(isObject) : [];
|
|
232
|
-
const admitted = new Set(admissions.filter((a) => a.state === 'admitted' && a.capability
|
|
234
|
+
const admitted = new Set(admissions.filter((a) => a.state === 'admitted' && sameIdentifier(a.capability, route.capability)).map((a) => refKey(a.provider)));
|
|
233
235
|
const findings = [];
|
|
234
236
|
routeCandidates(route).forEach((candidate, index) => {
|
|
235
237
|
const key = refKey(candidate.provider);
|
|
@@ -238,8 +240,9 @@ function routeBundle(value) {
|
|
|
238
240
|
if (isObject(value.binding)) {
|
|
239
241
|
const binding = value.binding;
|
|
240
242
|
const disagree = (where, message) => findings.push(finding('FAC-SEM-032', `/binding/${where}`, message));
|
|
241
|
-
|
|
242
|
-
if (binding.
|
|
243
|
+
// DEC-0076: project and capability are identifiers, compared by normal form.
|
|
244
|
+
if (!sameIdentifier(binding.project, route.project)) disagree('project', `the binding is for ${String(binding.project)}, the route for ${String(route.project)}`);
|
|
245
|
+
if (!sameIdentifier(binding.capability, route.capability)) disagree('capability', `the binding binds ${String(binding.capability)}, the route orders ${String(route.capability)}`);
|
|
243
246
|
if (binding.profileKind !== 'local-runner') disagree('profileKind', `a runner route serves the local-runner profile, not ${String(binding.profileKind)}`);
|
|
244
247
|
if (!routeCandidates(route).some((c) => sameRef(c.provider, binding.provider))) disagree('provider', `the binding's provider ${refKey(binding.provider)} is not a candidate of the route`);
|
|
245
248
|
if (binding.runnerRoute !== undefined && !sameRef(binding.runnerRoute, route.meta)) disagree('runnerRoute', `the binding pins ${refKey(binding.runnerRoute)}, not this route ${refKey(route.meta)}`);
|
|
@@ -263,8 +266,8 @@ function routeEvent(value) {
|
|
|
263
266
|
const findings = [];
|
|
264
267
|
const wrong = (where, message) => findings.push(finding('FAC-SEM-034', `/event${where}`, message));
|
|
265
268
|
if (!sameRef(event.route, route.meta)) wrong('/route', `the event names route ${refKey(event.route)}, not ${refKey(route.meta)}`);
|
|
266
|
-
if (event.project
|
|
267
|
-
if (event.capability
|
|
269
|
+
if (!sameIdentifier(event.project, route.project)) wrong('/project', `the event is for ${String(event.project)}, the route for ${String(route.project)}`);
|
|
270
|
+
if (!sameIdentifier(event.capability, route.capability)) wrong('/capability', `the event names ${String(event.capability)}, the route orders ${String(route.capability)}`);
|
|
268
271
|
const indexOf = (ref) => (typeof ref.index === 'number' && Number.isInteger(ref.index) ? ref.index : -1);
|
|
269
272
|
const candidateAt = (ref, where) => {
|
|
270
273
|
const index = indexOf(ref);
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -54,7 +54,11 @@ const O_EXLOCK = 0x20; // BSD/macOS: open(2) takes flock(LOCK_EX); absent from f
|
|
|
54
54
|
// DEC-0032: a local path starts with ~/ (or ~\ on Windows), / (never //) or a drive letter; a network share never does.
|
|
55
55
|
// DEC-0057: `//server/…` and `/\\server…` are shares on Windows too, so a leading slash is followed by neither.
|
|
56
56
|
const LOCAL_PATH = /^(~[\\/]|\/(?![\\/])|[A-Za-z]:[\\/])/;
|
|
57
|
-
|
|
57
|
+
// DEC-0061: `node` (Project Observatory's node supervises its children, no supervisor file); DEC-0069: `person` (an app a
|
|
58
|
+
// person installs and starts; its descriptor says it is installed, never that it runs).
|
|
59
|
+
export const MANAGERS = Object.freeze(['launchd', 'systemd', 'task-scheduler', 'node', 'person', 'none']);
|
|
60
|
+
/** Managers that write no launchd label or plist (DEC-0057, DEC-0061, DEC-0069). */
|
|
61
|
+
const NOT_LAUNCHD = Object.freeze(['systemd', 'task-scheduler', 'node', 'person']);
|
|
58
62
|
const AUTH_HEADER = /^[A-Za-z][A-Za-z0-9-]{0,63}$/;
|
|
59
63
|
const UNIT = /^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$/;
|
|
60
64
|
const TASK = /^\\[A-Za-z0-9 ._\\-]{1,254}$/;
|
|
@@ -521,7 +525,9 @@ export function validateDescriptor(d) {
|
|
|
521
525
|
const problems = [];
|
|
522
526
|
const remote = placementOf(d) === 'remote';
|
|
523
527
|
if (d.placement !== undefined && !PLACEMENTS.includes(d.placement)) problems.push('placement must be local or remote');
|
|
524
|
-
|
|
528
|
+
// DEC-0069: an app's descriptor says it is installed, never that it runs; its live origin is in its own liveness file.
|
|
529
|
+
const app = d.lifecycle?.manager === 'person';
|
|
530
|
+
const required = ['protocol', 'id', 'instance', 'name', ...(app ? [] : ['origin']), 'auth', 'lifecycle', 'installedAt', 'installedBy'];
|
|
525
531
|
for (const key of remote ? required : [...required, 'paths']) {
|
|
526
532
|
if (!(key in d)) problems.push(`missing ${key}`);
|
|
527
533
|
}
|
|
@@ -530,7 +536,7 @@ export function validateDescriptor(d) {
|
|
|
530
536
|
if (!ID.test(d.id)) problems.push(`id must match ${ID}`);
|
|
531
537
|
if (!INSTANCE.test(d.instance)) problems.push(`instance must match ${INSTANCE}`);
|
|
532
538
|
if (remote) problems.push(...remoteOriginProblems(d.origin));
|
|
533
|
-
else if (!ORIGIN.test(d.origin)) problems.push('origin must be http://127.0.0.1:<port>');
|
|
539
|
+
else if (d.origin !== undefined && !ORIGIN.test(d.origin)) problems.push('origin must be http://127.0.0.1:<port>');
|
|
534
540
|
if (!d.auth?.tokenFile) problems.push('auth.tokenFile is required');
|
|
535
541
|
else if (!LOCAL_PATH.test(String(d.auth.tokenFile))) problems.push('auth.tokenFile must be a local path, never a network share');
|
|
536
542
|
for (const value of [...(d.paths?.data !== undefined ? [d.paths.data] : []), ...(d.paths?.logs ?? [])]) {
|
|
@@ -543,14 +549,18 @@ export function validateDescriptor(d) {
|
|
|
543
549
|
}
|
|
544
550
|
const life = d.lifecycle ?? {};
|
|
545
551
|
const manager = life.manager;
|
|
546
|
-
if (!MANAGERS.includes(manager)) problems.push('lifecycle.manager must be launchd, systemd, task-scheduler or none');
|
|
552
|
+
if (!MANAGERS.includes(manager)) problems.push('lifecycle.manager must be launchd, systemd, task-scheduler, node, person or none');
|
|
553
|
+
// DEC-0069: `app` is the installed app of a person's descriptor, and nothing else's.
|
|
554
|
+
if (manager === 'person' && life.app === undefined) problems.push("a person's app declares lifecycle.app, the installed app");
|
|
555
|
+
if (life.app !== undefined && manager !== 'person') problems.push('lifecycle.app belongs to manager person only');
|
|
556
|
+
if (life.app !== undefined && !LOCAL_PATH.test(String(life.app))) problems.push('lifecycle.app must be a local path');
|
|
547
557
|
if (manager === 'launchd' && !(life.label && String(life.plist ?? '').endsWith('.plist'))) {
|
|
548
558
|
problems.push('a launchd service declares label and plist');
|
|
549
559
|
}
|
|
550
560
|
if (life.plist !== undefined && !LOCAL_PATH.test(String(life.plist))) problems.push('lifecycle.plist must be a local path');
|
|
551
|
-
// DEC-0057: a descriptor names one supervisor; launchd's label and plist never sit beside another.
|
|
561
|
+
// DEC-0057, DEC-0061, DEC-0069: a descriptor names one supervisor; launchd's label and plist never sit beside another.
|
|
552
562
|
for (const field of ['label', 'plist']) {
|
|
553
|
-
if (life[field] !== undefined &&
|
|
563
|
+
if (life[field] !== undefined && NOT_LAUNCHD.includes(manager)) problems.push(`lifecycle.${field} belongs to launchd, not to ${manager}`);
|
|
554
564
|
}
|
|
555
565
|
// DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
|
|
556
566
|
for (const [field, owner, pattern] of [['unit', 'systemd', UNIT], ['task', 'task-scheduler', TASK]]) {
|
|
@@ -565,6 +575,7 @@ export function validateDescriptor(d) {
|
|
|
565
575
|
}
|
|
566
576
|
if (d.commands?.update !== undefined) problems.push('a remote service declares no update command');
|
|
567
577
|
}
|
|
578
|
+
problems.push(...descriptorMemberProblems(d));
|
|
568
579
|
for (const [name, argv] of Object.entries(d.commands ?? {})) {
|
|
569
580
|
if (!['doctor', 'update'].includes(name)) problems.push(`unknown command ${name}`);
|
|
570
581
|
else if (!Array.isArray(argv) || !argv.length || !argv.every((a) => typeof a === 'string')) problems.push(`command ${name} must be an argument array`);
|
|
@@ -574,6 +585,127 @@ export function validateDescriptor(d) {
|
|
|
574
585
|
}
|
|
575
586
|
|
|
576
587
|
/** DEC-0019: `local` unless the descriptor says `remote`. */
|
|
588
|
+
// --- DEC-0061 and DEC-0069: who installed it, how its model calls sign in, offered to others, an app's own descriptor ---
|
|
589
|
+
|
|
590
|
+
/** The organization edition's data classes, least sensitive first (DEC-0061, DEC-0053). */
|
|
591
|
+
export const PEER_DATA_CLASSES = Object.freeze(['public', 'internal', 'confidential', 'restricted']);
|
|
592
|
+
/** The kind of a runner's sign-in, never a credential's value (DEC-0061). */
|
|
593
|
+
export const CREDENTIAL_KINDS = Object.freeze(['subscription', 'key', 'platform-metered', 'none']);
|
|
594
|
+
/** A subscription runs only in its vendor's runner (DEC-0046): runner kind → login. */
|
|
595
|
+
export const SUBSCRIPTION_RUNNERS = Object.freeze({ 'claude-code': 'claude', 'kimi-code': 'kimi', codex: 'chatgpt' });
|
|
596
|
+
const PEER_ENDPOINT = /^https:\/\/((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?$/;
|
|
597
|
+
const RESIDENCY = /^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])$/;
|
|
598
|
+
const RUNNER_KIND_NAME = /^[a-z][a-z0-9-]{1,62}$/;
|
|
599
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
600
|
+
const text = (v, low = 1, high = 200) => typeof v === 'string' && v.length >= low && v.length <= high;
|
|
601
|
+
/** A JSON Pointer token (RFC 6901 §4). */
|
|
602
|
+
const pointer = (name) => String(name).replace(/~/g, '~0').replace(/\//g, '~1');
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* DEC-0061 and DEC-0069 on a descriptor's `installedBy`, `runner` and `peer`, as the schema shapes them, with FAC-SEM-085
|
|
606
|
+
* (a peer on a reserved name) and FAC-SEM-086 (a credential-shaped value in `runner` or `installedBy`, named by its
|
|
607
|
+
* place, never its value).
|
|
608
|
+
*/
|
|
609
|
+
export function descriptorMemberProblems(d) {
|
|
610
|
+
const problems = [];
|
|
611
|
+
const life = isObj(d.lifecycle) ? d.lifecycle : {};
|
|
612
|
+
const by = d.installedBy;
|
|
613
|
+
const places = [];
|
|
614
|
+
if (typeof by === 'string') {
|
|
615
|
+
if (!text(by)) problems.push('installedBy is the installer and its version, 1 to 200 characters');
|
|
616
|
+
places.push(['/installedBy', by]);
|
|
617
|
+
} else if (isObj(by)) {
|
|
618
|
+
for (const k of Object.keys(by)) if (!['by', 'role', 'tool'].includes(k)) problems.push(`installedBy has no member ${k}`);
|
|
619
|
+
if (!['person', 'node', 'organization'].includes(by.by)) problems.push('installedBy.by is person, node or organization');
|
|
620
|
+
if (by.by === 'person' && !['operator', 'administrator'].includes(by.role)) problems.push('installedBy.role is operator or administrator for a person');
|
|
621
|
+
if (by.by !== 'person' && 'role' in by) problems.push('installedBy.role belongs to a person only');
|
|
622
|
+
if (!text(by.tool)) problems.push('installedBy.tool is the installer and its version, 1 to 200 characters');
|
|
623
|
+
for (const [k, v] of Object.entries(by)) places.push([`/installedBy/${pointer(k)}`, v]);
|
|
624
|
+
} else problems.push("installedBy is the installer's name or {by, role?, tool}");
|
|
625
|
+
if (life.manager === 'person' && !(isObj(by) && by.by === 'person')) problems.push("an app's descriptor names who installed it as a person: installedBy {by: person, role, tool} (DEC-0069)");
|
|
626
|
+
let runner = d.runner;
|
|
627
|
+
if ('runner' in d) {
|
|
628
|
+
if (!isObj(runner)) { problems.push('runner is {kind?, credentialKind}'); runner = {}; }
|
|
629
|
+
for (const k of Object.keys(runner)) if (!['kind', 'credentialKind'].includes(k)) problems.push(`runner has no member ${k}`);
|
|
630
|
+
if (!CREDENTIAL_KINDS.includes(runner.credentialKind)) problems.push('runner.credentialKind is subscription, key, platform-metered or none');
|
|
631
|
+
if ('kind' in runner && !(typeof runner.kind === 'string' && RUNNER_KIND_NAME.test(runner.kind))) problems.push("runner.kind is a runner kind's name");
|
|
632
|
+
if (runner.credentialKind === 'subscription' && !Object.hasOwn(SUBSCRIPTION_RUNNERS, runner.kind)) problems.push("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex");
|
|
633
|
+
for (const [k, v] of Object.entries(runner)) places.push([`/runner/${pointer(k)}`, v]);
|
|
634
|
+
}
|
|
635
|
+
if ('peer' in d) {
|
|
636
|
+
let peer = d.peer;
|
|
637
|
+
if (!isObj(peer)) { problems.push('peer is {endpoint, custody, residency, maxDataClass}'); peer = {}; }
|
|
638
|
+
for (const k of Object.keys(peer)) if (!['endpoint', 'custody', 'residency', 'maxDataClass'].includes(k)) problems.push(`peer has no member ${k}`);
|
|
639
|
+
const m = typeof peer.endpoint === 'string' && peer.endpoint.length <= 2048 ? PEER_ENDPOINT.exec(peer.endpoint) : null;
|
|
640
|
+
if (!m) problems.push('peer.endpoint is https://<dns-name>[:<port>][/<path>], no query, fragment, userinfo or IP literal');
|
|
641
|
+
else if (RESERVED_HOST.test(m[1])) problems.push(`FAC-SEM-085: a service offered to others cannot be reached at the reserved name ${m[1]}`);
|
|
642
|
+
if (!['organization-managed', 'personal'].includes(peer.custody)) problems.push('peer.custody is organization-managed or personal');
|
|
643
|
+
const r = peer.residency;
|
|
644
|
+
if (!Array.isArray(r) || r.length < 1 || r.length > 32 || new Set(r.map((x) => JSON.stringify(x))).size !== r.length || !r.every((x) => typeof x === 'string' && RESIDENCY.test(x))) {
|
|
645
|
+
problems.push('peer.residency is 1 to 32 distinct ISO 3166-1 alpha-2 codes in upper case, EU admitted, no user-assigned code');
|
|
646
|
+
}
|
|
647
|
+
if (!PEER_DATA_CLASSES.includes(peer.maxDataClass)) problems.push('peer.maxDataClass is public, internal, confidential or restricted');
|
|
648
|
+
if (!('runner' in d)) problems.push('a service offered to others declares runner (DEC-0061)');
|
|
649
|
+
else if (isObj(runner) && runner.credentialKind === 'subscription') problems.push('a service offered to others never runs on a subscription (DEC-0061)');
|
|
650
|
+
}
|
|
651
|
+
for (const [place, value] of places) {
|
|
652
|
+
if (typeof value === 'string' && secretArgument(value)) problems.push(`FAC-SEM-086: ${place} holds a value shaped like a credential; it names a kind, a role or an installer`);
|
|
653
|
+
}
|
|
654
|
+
return problems;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* The host's check before it routes a request from anyone but the service's operator (DEC-0061, FAC-SEM-088):
|
|
659
|
+
* `not-offered`, `peer-invalid`, `data-class-unknown` or `above-max-data-class`; null when it may. The contract's `peerRefusal`.
|
|
660
|
+
*/
|
|
661
|
+
export function peerRefusal(descriptor, dataClass) {
|
|
662
|
+
if (!isObj(descriptor) || !isObj(descriptor.peer)) return 'not-offered';
|
|
663
|
+
const max = typeof descriptor.peer.maxDataClass === 'string' ? PEER_DATA_CLASSES.indexOf(descriptor.peer.maxDataClass) : -1;
|
|
664
|
+
if (max < 0) return 'peer-invalid';
|
|
665
|
+
const asked = typeof dataClass === 'string' ? PEER_DATA_CLASSES.indexOf(dataClass) : -1;
|
|
666
|
+
if (asked < 0) return 'data-class-unknown';
|
|
667
|
+
return asked > max ? 'above-max-data-class' : null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* What an app that writes its own descriptor does with it on a start (DEC-0069): `keep` from a translocated copy (macOS
|
|
672
|
+
* App Translocation); otherwise `write` when there is none, when it is not an app's descriptor, or when its version
|
|
673
|
+
* (`installedBy.tool`) or its path (`lifecycle.app`) differs; `keep` otherwise. The contract's `appDescriptorAction`.
|
|
674
|
+
*/
|
|
675
|
+
export function appDescriptorAction(existing, tool, appPath) {
|
|
676
|
+
if (appPath.includes('/AppTranslocation/')) return 'keep';
|
|
677
|
+
if (!isObj(existing) || !isObj(existing.installedBy)) return 'write';
|
|
678
|
+
const recorded = isObj(existing.lifecycle) ? existing.lifecycle.app : undefined;
|
|
679
|
+
return existing.installedBy.tool === tool && recorded === appPath ? 'keep' : 'write';
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Whether a reader treats a descriptor as stale (DEC-0069): an app's descriptor whose installed app it did not find. */
|
|
683
|
+
export function appDescriptorStale(descriptor, appPresent) {
|
|
684
|
+
if (!isObj(descriptor) || !isObj(descriptor.lifecycle) || descriptor.lifecycle.manager !== 'person') return false;
|
|
685
|
+
return !appPresent;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Whether `lifecycle.app` of an app's descriptor exists on this machine (a bundle is a folder, an executable a file). */
|
|
689
|
+
export function appPresent(descriptor) {
|
|
690
|
+
const app = isObj(descriptor?.lifecycle) ? descriptor.lifecycle.app : undefined;
|
|
691
|
+
try { return typeof app === 'string' && fs.existsSync(expand(app)); } catch { return false; }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Fabric's own descriptor (Fabric ADR-0139): `services/fabric.default.json`. */
|
|
695
|
+
export const FABRIC_SERVICE = Object.freeze(['fabric', 'default']);
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* `present` or `absent` for Fabric's button (DEC-0040 FB-5, DEC-0069, Fabric SCN-142): present when Fabric's own
|
|
699
|
+
* descriptor is registered and, for an app's descriptor, its app is still installed; a server sets `data-fabric="absent"`
|
|
700
|
+
* on the button's script otherwise. The button only follows a link, so the liveness file is not read here.
|
|
701
|
+
*/
|
|
702
|
+
export function fabricButtonPresence(dir = servicesDir()) {
|
|
703
|
+
let found;
|
|
704
|
+
try { found = JSON.parse(fs.readFileSync(descriptorPath(...FABRIC_SERVICE, dir), 'utf8')); } catch { return 'absent'; }
|
|
705
|
+
if (!isObj(found) || validateDescriptor(found).length) return 'absent';
|
|
706
|
+
return appDescriptorStale(found, appPresent(found)) ? 'absent' : 'present';
|
|
707
|
+
}
|
|
708
|
+
|
|
577
709
|
export function placementOf(d) {
|
|
578
710
|
return d?.placement === 'remote' ? 'remote' : 'local';
|
|
579
711
|
}
|
|
@@ -597,8 +729,8 @@ export function writeDescriptor(d, dir = servicesDir()) {
|
|
|
597
729
|
const problems = validateDescriptor(d);
|
|
598
730
|
if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
|
|
599
731
|
const me = `${d.id}.${d.instance}`;
|
|
600
|
-
// DEC-0019: only a local placement claims a port on this computer.
|
|
601
|
-
const port = placementOf(d) === 'remote' ? null : portOf(d.origin);
|
|
732
|
+
// DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
|
|
733
|
+
const port = placementOf(d) === 'remote' || d.origin === undefined ? null : portOf(d.origin);
|
|
602
734
|
for (const [file, other] of readDescriptors(dir)) {
|
|
603
735
|
const key = `${other.id}.${other.instance ?? 'default'}`;
|
|
604
736
|
if (key === me) continue;
|
|
@@ -661,6 +793,8 @@ const PROFILE_ID = /^[a-z][a-z0-9-]{0,62}$/;
|
|
|
661
793
|
const RUNNER_KIND = /^[a-z][a-z0-9-]{1,62}$/;
|
|
662
794
|
const MODEL_ID = /^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._:-]{0,127}$/;
|
|
663
795
|
export const ROUTES = Object.freeze(['subscription', 'key']);
|
|
796
|
+
/** A usage row's route (DEC-0046, DEC-0061): beside a configuration's two, the hosting platform's own metered models. */
|
|
797
|
+
export const USAGE_ROUTES = Object.freeze([...ROUTES, 'platform-metered']);
|
|
664
798
|
|
|
665
799
|
/** DEC-0046: the well-known `configuration` — {slots, resolved} as service-well-known.schema.json types it. */
|
|
666
800
|
export function configurationProblems(doc) {
|
|
@@ -766,7 +900,7 @@ export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, c
|
|
|
766
900
|
if (costUsd !== null && (typeof costUsd !== 'number' || !Number.isFinite(costUsd) || costUsd < 0)) throw new ServiceError('costUsd must be a non-negative number or null.');
|
|
767
901
|
const basis = costBasis ?? (costUsd === null ? 'unknown' : 'provider');
|
|
768
902
|
if (!COST_BASES.includes(basis) || (costUsd === null) !== (basis === 'unknown')) throw new ServiceError('costBasis is provider or price-list with a cost, unknown without one.');
|
|
769
|
-
if (route !== undefined && !
|
|
903
|
+
if (route !== undefined && !USAGE_ROUTES.includes(route)) throw new ServiceError('route is subscription, key or platform-metered (DEC-0046, DEC-0061).');
|
|
770
904
|
return { at: at ?? nowIso(), provider, model, ...counts, costUsd, costBasis: basis, ...(route === undefined ? {} : { route }) };
|
|
771
905
|
}
|
|
772
906
|
|
|
@@ -1650,13 +1784,20 @@ export function remoteTokenFile(id, instance = 'default', dir = servicesDir()) {
|
|
|
1650
1784
|
* a secret. Returns the descriptor path. `token` is read from the caller — a file or stdin —
|
|
1651
1785
|
* never from an argument vector.
|
|
1652
1786
|
*/
|
|
1653
|
-
|
|
1787
|
+
/**
|
|
1788
|
+
* DEC-0019, installer side: register an online service on THIS computer — the token file (0600, never printed) and the
|
|
1789
|
+
* descriptor. DEC-0061: `installedBy` defaults to `{by: 'person', role: 'operator', tool: 'fabric-service register-remote'}`
|
|
1790
|
+
* (a person runs this); `runner` (`{kind?, credentialKind}`) says how its model calls sign in, as a kind and never a
|
|
1791
|
+
* credential; `peer` (`{endpoint, custody, residency, maxDataClass}`) offers it to others and needs `runner`.
|
|
1792
|
+
*/
|
|
1793
|
+
export function registerRemote({ id, instance = 'default', name, summary, origin, token, doctor, dir = servicesDir(),
|
|
1794
|
+
installedBy = { by: 'person', role: 'operator', tool: 'fabric-service register-remote' }, runner, peer }) {
|
|
1654
1795
|
if (!token || String(token).trim().length < 16) throw new ServiceError('the service token must be at least 16 characters.');
|
|
1655
1796
|
const tokenFile = remoteTokenFile(id, instance, dir);
|
|
1656
1797
|
const d = {
|
|
1657
1798
|
protocol: PROTOCOL, id, instance, name, ...(summary ? { summary } : {}), placement: 'remote', origin,
|
|
1658
1799
|
auth: { tokenFile }, lifecycle: { manager: 'none' }, ...(doctor ? { commands: { doctor } } : {}),
|
|
1659
|
-
installedAt: nowIso(), installedBy,
|
|
1800
|
+
installedAt: nowIso(), installedBy, ...(runner ? { runner: { ...runner } } : {}), ...(peer ? { peer: { ...peer } } : {}),
|
|
1660
1801
|
};
|
|
1661
1802
|
const problems = validateDescriptor(d);
|
|
1662
1803
|
if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py
CHANGED
|
@@ -826,15 +826,21 @@ def _secret_references(value: Dict[str, Any], flag) -> None:
|
|
|
826
826
|
slots.append(key)
|
|
827
827
|
|
|
828
828
|
|
|
829
|
+
def _grant_key(ident: Any) -> Any:
|
|
830
|
+
"""A grant's id is a URI, so grants and their revocations are keyed by its normal form (DEC-0076)."""
|
|
831
|
+
import fabric_identifiers # noqa: E402 - beside this file
|
|
832
|
+
return fabric_identifiers.normal_identifier(ident) if isinstance(ident, str) else ident
|
|
833
|
+
|
|
834
|
+
|
|
829
835
|
def _grants_and_revocations(value: Dict[str, Any], flag) -> None:
|
|
830
836
|
grant_ids: List[Any] = []
|
|
831
837
|
for index, grant in enumerate(_list(_get(value, "shareGrants"))):
|
|
832
838
|
if _get(grant, "principalKind") != "external":
|
|
833
839
|
flag("/shareGrants/%d/principalKind" % index, "a share grant is to a principal outside the estate: principalKind external")
|
|
834
840
|
ident = _get(_get(grant, "meta"), "id") if isinstance(_get(grant, "meta"), dict) else _MISSING
|
|
835
|
-
if _contains(grant_ids, ident):
|
|
841
|
+
if _contains(grant_ids, _grant_key(ident)):
|
|
836
842
|
flag("/shareGrants/%d/meta/id" % index, "grant %s is listed twice" % _js_string(ident))
|
|
837
|
-
grant_ids.append(ident)
|
|
843
|
+
grant_ids.append(_grant_key(ident))
|
|
838
844
|
machines = _list(_get(value, "machines"))
|
|
839
845
|
listed = {
|
|
840
846
|
"authority": [_get(e, "credentialId") for e in _list(_get(value, "authorities"))],
|
|
@@ -847,11 +853,12 @@ def _grants_and_revocations(value: Dict[str, Any], flag) -> None:
|
|
|
847
853
|
}
|
|
848
854
|
seen: List[str] = []
|
|
849
855
|
for index, revocation in enumerate(_list(_get(value, "revocations"))):
|
|
850
|
-
|
|
856
|
+
ref = _grant_key(_get(revocation, "ref")) if _get(revocation, "kind") == "grant" else _get(revocation, "ref")
|
|
857
|
+
key = "%s %s" % (_s(revocation, "kind"), _js_string(ref))
|
|
851
858
|
if key in seen:
|
|
852
859
|
flag("/revocations/%d" % index, "the %s %s is revoked twice" % (_s(revocation, "kind"), _s(revocation, "ref")))
|
|
853
860
|
seen.append(key)
|
|
854
|
-
if _contains(listed.get(_s(revocation, "kind"), []),
|
|
861
|
+
if _contains(listed.get(_s(revocation, "kind"), []), ref):
|
|
855
862
|
flag("/revocations/%d/ref" % index, "the record still lists the %s it revokes: revocation outranks addition" % _s(revocation, "kind"))
|
|
856
863
|
|
|
857
864
|
|