@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.
Files changed (25) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +17 -5
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_identifiers.py +182 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +7 -2
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +7 -4
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +19 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +19 -5
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +19 -2
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -4
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-identifiers.mjs +156 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +9 -6
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +152 -11
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +11 -4
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py +182 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +11 -6
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +204 -15
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +2 -1
  23. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  24. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  25. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
@@ -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
+ }
@@ -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
- const refKey = (ref) => (isObject(ref) ? `${String(ref.id)}@${String(ref.revision)}#${String(ref.contentHash)}` : '');
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 === route.capability).map((a) => refKey(a.provider)));
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
- if (binding.project !== route.project) disagree('project', `the binding is for ${String(binding.project)}, the route for ${String(route.project)}`);
242
- if (binding.capability !== route.capability) disagree('capability', `the binding binds ${String(binding.capability)}, the route orders ${String(route.capability)}`);
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 !== route.project) wrong('/project', `the event is for ${String(event.project)}, the route for ${String(route.project)}`);
267
- if (event.capability !== route.capability) wrong('/capability', `the event names ${String(event.capability)}, the route orders ${String(route.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);
@@ -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
- export const MANAGERS = Object.freeze(['launchd', 'systemd', 'task-scheduler', 'none']);
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
- const required = ['protocol', 'id', 'instance', 'name', 'origin', 'auth', 'lifecycle', 'installedAt', 'installedBy'];
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 && ['systemd', 'task-scheduler'].includes(manager)) problems.push(`lifecycle.${field} belongs to launchd, not to ${manager}`);
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 && !ROUTES.includes(route)) throw new ServiceError('route is subscription or key (DEC-0046).');
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
- export function registerRemote({ id, instance = 'default', name, summary, origin, token, doctor, dir = servicesDir(), installedBy = 'fabric-service register-remote' }) {
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('; ')}.`);
@@ -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
- key = "%s %s" % (_s(revocation, "kind"), _s(revocation, "ref"))
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"), []), _get(revocation, "ref")):
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