@passioncode-ai/passioncode 0.2.38 → 0.2.40

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 (73) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +29 -11
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +58 -5
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +11 -3
  10. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  11. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +8 -6
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +50 -12
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +5 -4
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +5 -3
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +3 -1
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +3 -2
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +58 -5
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +13 -4
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +13 -1
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +60 -0
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +159 -2
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +8 -2
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +4 -2
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +14 -2
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +2 -1
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +40 -3
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +8 -6
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +67 -2
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +371 -2
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +170 -0
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +275 -0
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +3 -1
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +151 -0
  37. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +38 -8
  38. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +31 -2
  39. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +6 -0
  40. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +68 -3
  41. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +31 -1
  42. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +247 -0
  43. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-page-links.mjs +144 -0
  44. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +22 -1
  45. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +2 -2
  46. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
  47. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +30 -2
  48. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -4
  49. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +209 -0
  50. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +340 -0
  51. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +2 -1
  52. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +170 -0
  53. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +36 -12
  54. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +36 -3
  55. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +7 -0
  56. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +242 -11
  57. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +70 -2
  58. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +324 -0
  59. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_page_links.py +559 -0
  60. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +21 -0
  61. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +2 -2
  62. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +9 -0
  63. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +35 -0
  64. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +6 -4
  65. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +413 -25
  66. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -11
  67. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +58 -5
  68. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +11 -3
  69. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +8 -6
  70. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +87 -10
  71. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +12 -0
  72. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
  73. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -0,0 +1,144 @@
1
+ // A service's pages as Fabric Dashboards' open_link (Fabric Agent Contract DEC-0123, PL-1 to PL-4, FAC-SEM-143;
2
+ // `src/page-link-rules.ts` at the pin): build and read the open_link, tell a raw local address, and the findings on a
3
+ // link a document hands a person and on a URL-mode input request. The twin of fabric_page_links.py, held to the
4
+ // contract's answers by test/fixtures/contract/page-link-vectors.json and page-link-extra-vectors.json. It reads URLs
5
+ // with Node's `URL` and `URLSearchParams`, as the reference does. Part of the Node service kit (FAA-65, layer L3).
6
+ // Node 20+, no dependency.
7
+ import { ID, INSTANCE as INSTANCE_NAME, ServiceError } from './fabric-base.mjs';
8
+ import { isObject } from './fabric-jsvalue.mjs';
9
+ import { credentialName, rawValues, secretValue } from './fabric-secrets.mjs';
10
+
11
+ /** A service id and an instance, as `service-common.schema.json` defines them. */
12
+ export const SERVICE_ID = ID;
13
+ export const INSTANCE = INSTANCE_NAME;
14
+ /** A path on the service origin, as `service-common.schema.json` `originPath` writes it: no whitespace. */
15
+ export const ORIGIN_PATH = /^\/(?!\/)\S*$/;
16
+ /** What the host's `safePath` refuses anywhere in a path: a backslash and a C0 or DEL control character. */
17
+ const HOST_REFUSED = /[\\\u0000-\u001f\u007f]/;
18
+ /** The host's limit on a path; the link around it is bounded only to keep a reader's work finite. */
19
+ export const MAX_PAGE_PATH_LENGTH = 2048;
20
+ export const MAX_PAGE_LINK_LENGTH = 20_000;
21
+ const BASE = 'http://origin.invalid';
22
+ /** A UTF-16 surrogate without its pair, which no encoding carries faithfully: it would read back as U+FFFD. */
23
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
24
+
25
+ /** What `dashboardsLink` and `pageAnswer` throw for an id, an instance or a path the grammar refuses. */
26
+ export class PageLinkError extends ServiceError {}
27
+
28
+ /**
29
+ * The open_link for the service `service.instance` (PL-1): its dashboard without `path`, or the page at `path` on its
30
+ * origin, encoded as Fabric Dashboards' `serviceLink` encodes it. Throws PageLinkError for what the grammar refuses.
31
+ */
32
+ export function dashboardsLink(service, instance, path) {
33
+ const problems = linkProblems(service, instance, path);
34
+ if (problems.length) throw new PageLinkError(problems.join('; '));
35
+ const base = `fabric-dashboards://service/${service}.${instance}`;
36
+ return path === undefined || path === null || path === '' ? base : `${base}?${new URLSearchParams({ path }).toString()}`;
37
+ }
38
+
39
+ const SHAPE = /^fabric-dashboards:\/\/service\/([^/?#]+)\/?(?:\?([^#]*))?$/;
40
+
41
+ /** Read an open_link: `{ok: true, service, instance, path?}` or `{ok: false, problems}`. */
42
+ export function parseDashboardsLink(url) {
43
+ if (typeof url !== 'string') return { ok: false, problems: ['not fabric-dashboards://service/<id>.<instance>[?path=<path>]'] };
44
+ if (url.length > MAX_PAGE_LINK_LENGTH) return { ok: false, problems: [`an open_link is at most ${MAX_PAGE_LINK_LENGTH} characters`] };
45
+ // The scheme and the host's verb are read without regard to case, as the host reads them.
46
+ const link = url.replace(/^fabric-dashboards:\/\/service\//i, 'fabric-dashboards://service/');
47
+ const match = SHAPE.exec(link);
48
+ if (!match) return { ok: false, problems: ['not fabric-dashboards://service/<id>.<instance>[?path=<path>]'] };
49
+ const key = match[1];
50
+ const dot = key.indexOf('.');
51
+ const service = dot < 0 ? key : key.slice(0, dot);
52
+ const instance = dot < 0 ? '' : key.slice(dot + 1);
53
+ if (!match[2]) {
54
+ const problems = linkProblems(service, instance);
55
+ return problems.length ? { ok: false, problems } : { ok: true, service, instance };
56
+ }
57
+ // The parameters as the host's URL parser reads them: a second `?` stays part of a name, and the query is
58
+ // percent-encoded before it is decoded.
59
+ const query = new URL(link).searchParams;
60
+ const paths = query.getAll('path');
61
+ if (paths.length !== 1) return { ok: false, problems: [paths.length ? 'path is given more than once' : 'an open_link names its path'] };
62
+ if ([...query.keys()].some((name) => name !== 'path')) return { ok: false, problems: ['an open_link carries its path and nothing else'] };
63
+ const path = paths[0];
64
+ if (path === '') return { ok: false, problems: ['the path is empty; a link to the dashboard itself names no path'] };
65
+ const problems = linkProblems(service, instance, path);
66
+ return problems.length ? { ok: false, problems } : { ok: true, service, instance, path };
67
+ }
68
+
69
+ function linkProblems(service, instance, path) {
70
+ const problems = [];
71
+ if (typeof service !== 'string' || !SERVICE_ID.test(service)) problems.push('the service is not a service id');
72
+ if (typeof instance !== 'string' || !INSTANCE.test(instance)) problems.push('the instance is not an instance name');
73
+ if (path === undefined || path === null || path === '') return problems;
74
+ if (typeof path !== 'string') problems.push('the path is not a path on the service origin');
75
+ else if (path.length > MAX_PAGE_PATH_LENGTH) problems.push(`the path is at most ${MAX_PAGE_PATH_LENGTH} characters`);
76
+ else if (!ORIGIN_PATH.test(path) || HOST_REFUSED.test(path) || LONE_SURROGATE.test(path) || new URL(path, BASE).origin !== BASE) problems.push('the path is not a path on the service origin');
77
+ else if (pathCarriesCredential(path)) problems.push("the path could carry a token or a login code; signing in is the host's");
78
+ return problems;
79
+ }
80
+
81
+ /** A login-code route, or a query or fragment piece that could carry a credential (FL-7: a link carries none). */
82
+ function pathCarriesCredential(path) {
83
+ // The route as the service resolves it: dot segments and letter case do not hide it.
84
+ if (/^\/fabric\/+v1\/+login(?:\/|$)/i.test(new URL(path, BASE).pathname)) return true;
85
+ // The query and the fragment, a hash route's own query (`#/jobs?token=`) included, as pieces of one query.
86
+ const rest = path.replace(/^[^?#]*/, '').replace(/^[?#]/, '').replace(/[#?]/g, '&');
87
+ if (!rest) return false;
88
+ const pieces = [...new URLSearchParams(rest)];
89
+ const raw = [...rawValues(rest).values()].flat();
90
+ return secretValue(rest) || raw.some(secretValue) || pieces.some(([name, value]) => (value !== '' && credentialName(name)) || secretValue(value));
91
+ }
92
+
93
+ /**
94
+ * The hosts that only this computer reaches, as WHATWG URL writes them: 127.0.0.0/8, `[::1]`, `[::ffff:127.x.y.z]`,
95
+ * `localhost` and names under it, and the unspecified addresses `0.0.0.0` and `[::]` a wildcard bind reports.
96
+ */
97
+ const LOOPBACK = /^(?:127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1?\]|\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]|localhost|[a-z0-9.-]+\.localhost)$/i;
98
+
99
+ /** True for an `http` or `https` address on a loopback host: a local service's raw address. */
100
+ export function isLoopbackPageUrl(url) {
101
+ let parsed;
102
+ try {
103
+ parsed = new URL(url);
104
+ } catch {
105
+ return false;
106
+ }
107
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && LOOPBACK.test(parsed.hostname.replace(/\.$/, ''));
108
+ }
109
+
110
+ /** FAC-SEM-143 for one link a document hands a person: a raw loopback address, or an open_link outside its grammar. */
111
+ export function pageLinkFindings(url, instancePath, label) {
112
+ if (typeof url !== 'string') return [];
113
+ if (isLoopbackPageUrl(url)) return [{ code: 'FAC-SEM-143', instancePath, message: `${label} hands a person a raw local address; give Fabric Dashboards' open_link (DEC-0123 PL-1)` }];
114
+ // Only what a URL parser strips around a URL: C0 controls and space.
115
+ const bare = url.replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g, '');
116
+ if (/^\s*fabric-dashboards:/i.test(url) || parsesAsDashboards(url)) {
117
+ const parsed = parseDashboardsLink(bare);
118
+ if (!parsed.ok) return [{ code: 'FAC-SEM-143', instancePath, message: `${label} is not an open_link: ${parsed.problems.join('; ')} (DEC-0123 PL-1)` }];
119
+ }
120
+ return [];
121
+ }
122
+
123
+ function parsesAsDashboards(url) {
124
+ try {
125
+ return new URL(url).protocol === 'fabric-dashboards:';
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ /** FAC-SEM-143 on an input request in URL mode (kind `interop-input-requests`): its `url` is a link a person opens. */
132
+ export function inputRequestLinkFindings(value) {
133
+ const requests = isObject(value?.inputRequests) ? value.inputRequests : {};
134
+ return Object.entries(requests).flatMap(([key, request]) => {
135
+ const params = isObject(request) && isObject(request.params) ? request.params : {};
136
+ return params.mode === 'url' ? pageLinkFindings(params.url, `/inputRequests/${key}/params/url`, `input request ${key}`) : [];
137
+ });
138
+ }
139
+
140
+ /** Kind `page-link` (`{url}`): a link a service, its tools or its messages hand a person, such as a tool answer's `open_link`. */
141
+ export function pageLinkRules(kind, value) {
142
+ if (kind !== 'page-link') return undefined;
143
+ return pageLinkFindings(value?.url, '/url', 'the link');
144
+ }
@@ -4,7 +4,7 @@ import crypto from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
- import { ID, ServiceError, expand } from './fabric-base.mjs';
7
+ import { ID, INSTANCE, ServiceError, expand } from './fabric-base.mjs';
8
8
  import { windowsProtect } from './fabric-platform.mjs';
9
9
 
10
10
  // DEC-0032: LOCALAPPDATA, not APPDATA, so token files never roam with the profile.
@@ -39,6 +39,27 @@ export function serviceDirs(id, platform = process.platform) {
39
39
  };
40
40
  }
41
41
 
42
+ /**
43
+ * One instance's folders (FAA-125, brief D16): `default` keeps serviceDirs (an existing install keeps its data, logs
44
+ * and lock); any other is a sibling named as its descriptor, `<id>.<instance>`, never inside the default tree.
45
+ */
46
+ export function instanceDirs(id, instance = 'default', platform = process.platform) {
47
+ const personal = serviceDirs(id, platform);
48
+ if (typeof instance !== 'string' || !INSTANCE.test(instance)) throw new ServiceError(`Instance ${JSON.stringify(instance)} must match ${INSTANCE}.`);
49
+ if (instance === 'default') return personal;
50
+ const folder = `${id}.${instance}`;
51
+ if (platform === 'win32') {
52
+ const data = path.join(path.dirname(personal.data), folder);
53
+ return { data, logs: path.join(data, 'Logs'), cache: path.join(data, 'Cache') };
54
+ }
55
+ if (platform === 'darwin') return Object.fromEntries(Object.entries(personal).map(([key, dir]) => [key, path.join(path.dirname(dir), folder)]));
56
+ return {
57
+ data: path.join(path.dirname(personal.data), folder),
58
+ logs: path.join(path.dirname(path.dirname(personal.logs)), folder, 'logs'),
59
+ cache: path.join(path.dirname(personal.cache), folder),
60
+ };
61
+ }
62
+
42
63
  export function ensurePrivateDir(dir) {
43
64
  fs.mkdirSync(dir, { recursive: true });
44
65
  if (process.platform === 'win32') windowsProtect(dir, { folder: true });
@@ -8,10 +8,10 @@ import { credentialName, secretArgument, secretUrl, secretValue } from './fabric
8
8
  /** Each contract protocol's published revisions, oldest first (the contract's `PROTOCOL_REVISIONS`); a test holds it equal to the contract's. */
9
9
  export const PROTOCOL_REVISIONS = new Map(Object.entries({
10
10
  'access-log': ['1'], approval: ['1'], 'activity-summary': ['1'], 'agent-directory': ['1'], 'call-record': ['1'], 'coding-agents': ['1'], 'credential-decision': ['1'], 'credential-request': ['1'], 'credential-revocation': ['1'], 'enroll-token': ['1'], entitlement: ['1'], 'entitlement-key-set': ['1'], 'estate-record': ['1'], 'execution-packet': ['1'],
11
- 'fabric-activity': ['0.1'], 'fabric-agent-call': ['1'], 'fabric-connections': ['0.1'], 'fabric-credential-envelope': ['1'], 'fabric-load': ['0.1'], 'fabric-dashboard': ['0.1'], 'fabric-delegation': ['0.1'], 'fabric-device': ['0.1'],
11
+ 'fabric-actions': ['0.1'], 'fabric-activity': ['0.1'], 'fabric-agent-call': ['1'], 'fabric-connections': ['0.1'], 'fabric-credential-envelope': ['1'], 'fabric-load': ['0.1'], 'fabric-dashboard': ['0.1'], 'fabric-delegation': ['0.1'], 'fabric-device': ['0.1'],
12
12
  'fabric-interop': ['0.1', '0.2'], 'fabric-local-channel': ['0.1'], 'fabric-local-runner': ['0.1'], 'fabric-operator-channel': ['0.1'],
13
13
  'fabric-package': ['1'], 'fabric-project-comms': ['0.1'], 'fabric-proposal': ['1'], 'fabric-provider': ['0.1'], 'fabric-relay': ['0.1'],
14
- 'fabric-secret-envelope': ['1'], 'fabric-service': ['0.1'], 'fabric-settings-backup': ['1'], heartbeat: ['1'], 'learning-note': ['1'],
14
+ 'fabric-secret-envelope': ['1'], 'fabric-service': ['0.1'], 'fabric-settings-backup': ['1'], heartbeat: ['1'], 'learning-note': ['1'], mandate: ['1'],
15
15
  memory: ['0.1'], 'node-summary': ['1'], 'pairing-message': ['1'], pipeline: ['0.1'], 'repair-request': ['1'], 'repair-result': ['1'],
16
16
  'revocation-delta': ['1'], 'session-handoff': ['1'], 'service-load': ['1'],
17
17
  'share-registration': ['1'], 'share-registration-receipt': ['1'], 'support-quality-daily': ['1'], 'window-lease': ['1'],
@@ -40,6 +40,7 @@ export {
40
40
  feedDataFindings,
41
41
  feedDataProblems,
42
42
  } from './fabric-protocols.mjs';
43
+ export { ACTIONS_EXTENSION_KEY, actionDecision, actionRules, actionsExtension, actionsExtensionRules, actionsView, decisionShape } from './fabric-actions.mjs';
43
44
  export { CONNECTIONS_EXTENSION_KEY, connectionsExtension, connectionsRules, connectionsView } from './fabric-connections.mjs';
44
45
  export {
45
46
  EXIT_ALREADY_RUNNING,
@@ -6,8 +6,8 @@
6
6
  // never throws on malformed input. Normative source: the contract's src/share-registration.ts.
7
7
  // #region share-kit-node, docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md#share-grants-into-an-organization
8
8
 
9
- import { estateTime, grantContextFindings } from './fabric-estate.mjs';
10
- import { sameIdentifier } from './fabric-identifiers.mjs';
9
+ import { PERSONAL_CONTEXT, estateTime, grantContextFindings, placementGrantFindings } from './fabric-estate.mjs';
10
+ import { normalIdentifier, sameIdentifier } from './fabric-identifiers.mjs';
11
11
 
12
12
  export const SHARE_REGISTRATION_TYPE = 'share-registration/1';
13
13
  export const SHARE_REGISTRATION_RECEIPT_TYPE = 'share-registration-receipt/1';
@@ -91,6 +91,34 @@ export function grantUsageContextFindings(value) {
91
91
  return findings;
92
92
  }
93
93
 
94
+ /**
95
+ * Whether an organization's instance may run on `machineId` now (FAA-125, N-11): the placement of `agentRef` in
96
+ * `context` (as written; absent is personal) among that machine's packages, FAC-SEM-111 on it, and the grant's window at
97
+ * `atMs` (startsAt <= at < expiresAt; an unreadable instant or clock is outside it). Answers {ok: true, grant: {id,
98
+ * startsAt?, expiresAt?}} ({ok: true} for a personal placement) or {ok: false, code, detail} with code
99
+ * placement-missing, grant-refused or grant-window; never throws. Reads the revision it is given (FAC-SEM-111).
100
+ */
101
+ export function instanceShareGrant(record, machineId, agentRef, context, atMs) {
102
+ const rec = isObject(record) ? record : {};
103
+ const machine = list(rec.machines).find((entry) => entry.id === machineId) ?? {};
104
+ const placement = list(machine.packages).find((entry) => entry.agentRef === agentRef && (entry.context === undefined ? PERSONAL_CONTEXT : entry.context) === context);
105
+ if (!placement) return { ok: false, code: 'placement-missing', detail: 'this machine places no such agent in that context (FAC-SEM-074)' };
106
+ const findings = placementGrantFindings(rec, placement);
107
+ if (findings.length) return { ok: false, code: 'grant-refused', detail: findings.map((f) => f.message).join('; ') };
108
+ if (typeof placement.grant !== 'string') return { ok: true }; // a personal placement, which names no grant
109
+ const id = normalIdentifier(placement.grant);
110
+ const grant = list(rec.shareGrants).find((g) => g.context === context && isObject(g.meta) && typeof g.meta.id === 'string' && normalIdentifier(g.meta.id) === id);
111
+ if (!grant) return { ok: false, code: 'grant-refused', detail: 'the record has no share grant for this placement' }; // defensive, as in Python
112
+ const answer = { id: grant.meta.id };
113
+ for (const name of ['startsAt', 'expiresAt']) if (grant[name] !== undefined) answer[name] = grant[name];
114
+ const starts = answer.startsAt === undefined ? undefined : estateTime(answer.startsAt);
115
+ const expires = answer.expiresAt === undefined ? undefined : estateTime(answer.expiresAt);
116
+ if ((starts !== undefined && !(starts <= atMs)) || (expires !== undefined && !(atMs < expires))) {
117
+ return { ok: false, code: 'grant-window', detail: "now is outside the share grant's window, or an instant cannot be read" };
118
+ }
119
+ return { ok: true, grant: answer };
120
+ }
121
+
94
122
  /** Kinds grant, org-share-grants, share-registration and device-attribution (045's grant.usage clause); undefined otherwise. */
95
123
  export function shareRules(kind, value) {
96
124
  if (kind === 'grant') return grantFindings(value, '');
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { credentialName, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
8
8
  import { DRAIN_SECONDS, SUPERVISOR_ENV, ServiceError, TASK, UNIT } from './fabric-base.mjs';
9
- import { atomicWrite, serviceDirs } from './fabric-paths.mjs';
9
+ import { atomicWrite, instanceDirs } from './fabric-paths.mjs';
10
10
  import { SUPERVISOR_ARG, markerArguments } from './fabric-lock.mjs';
11
11
  import { URI } from './fabric-backups.mjs';
12
12
 
@@ -370,14 +370,15 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
370
370
  /**
371
371
  * Symmetric with launchdInstall (LC-14): bootout and WAIT until the job is gone, remove the plist, and reset the
372
372
  * override to enabled — launchctl has no verb that deletes an override, and a leftover `disabled` would keep a later
373
- * fresh install off. Data stays unless `purge` (then `serviceId` names whose data, logs and cache go). A job still
373
+ * fresh install off. Data stays unless `purge` (then `serviceId` names whose data, logs and cache go, and `instance` which
374
+ * instance's, so one context's purge never reaches another's, FAA-125). A job still
374
375
  * loaded at `timeout` seconds throws, leaving the plist and the data in place.
375
376
  */
376
- export async function launchdUninstall(label, plistPath, { timeout = 30, purge = false, serviceId, run = defaultRun, platform = process.platform, uid, sleep = pause } = {}) {
377
+ export async function launchdUninstall(label, plistPath, { timeout = 30, purge = false, serviceId, instance = 'default', run = defaultRun, platform = process.platform, uid, sleep = pause } = {}) {
377
378
  const domain = launchdDomain(platform, uid);
378
379
  launchdLabel(label);
379
380
  if (purge && !serviceId) throw new ServiceError('purge needs the service id whose data, logs and cache to remove.');
380
- const dirs = purge ? serviceDirs(serviceId, platform) : {};
381
+ const dirs = purge ? instanceDirs(serviceId, instance, platform) : {}; // one instance's folders only (FAA-125)
381
382
  const opts = { run, platform, uid };
382
383
  const target = `${domain}/${label}`;
383
384
  run(['launchctl', 'bootout', target]);
@@ -0,0 +1,209 @@
1
+ """The reading side of an A2A Agent Card (DEC-0122): the well-known document's `surfaces.a2a` member, read as the
2
+ contract's schema reads it (`a2a_surface_findings`, `a2a_view`; `a2a_surface` builds one), and what a public card must
3
+ not carry (`agent_card_findings`): no member about the running process, no secret, no `securitySchemes` entry naming
4
+ the descriptor's token header, every Fabric extension `required: false` (or absent, which A2A reads as false), and interface URLs where the placement puts
5
+ them. Serving a card, the A2A binding and a client are not here (FAA-10). Part of the service kit (layer L3).
6
+ Standard library only."""
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ import sys as _sys # noqa: E402
13
+ from pathlib import Path as _Path # noqa: E402
14
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
15
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
16
+
17
+ from fabric_base import ServiceError # noqa: E402
18
+ from fabric_jsvalue import js_keys # noqa: E402
19
+ from fabric_secrets import secret_value # noqa: E402
20
+
21
+ #: A2A 1.0 section 8.2: where a peer looks for the public card.
22
+ A2A_SURFACE_PATH = "/.well-known/agent-card.json"
23
+ #: The A2A version the contract pins (the A2A peer profile); `surfaces.a2a.protocolVersion` is this constant.
24
+ A2A_PROTOCOL_VERSION = "1.0"
25
+ #: The contract's extension namespace (decision D6): an extension whose uri starts here is a Fabric fact.
26
+ FABRIC_EXTENSION_PREFIX = "https://fabric.passioncode.ai/"
27
+ #: The well-known document's members about the running process, which a public card never carries (DEC-0122 item 2).
28
+ PROCESS_MEMBERS = ("build", "process", "pid", "status", "summary", "degraded")
29
+ MAX_PATH = 2048 # service-common.schema.json originPath maxLength, counted in code points as ajv counts it
30
+
31
+ # JavaScript's \s (the schema's pattern runs under ajv), not Python's: U+FEFF is one, U+001C and U+0085 are not.
32
+ _JS_SPACE = ("\t\n\x0b\x0c\r              "
33
+ "

   ")
34
+ _ORIGIN_PATH = re.compile("^/(?!/)[^%s]*\\Z" % _JS_SPACE)
35
+ _HTTPS = re.compile(r"^https://", re.IGNORECASE)
36
+
37
+
38
+ def _pointer(*parts: Any) -> str:
39
+ return "".join("/" + str(p).replace("~", "~0").replace("/", "~1") for p in parts)
40
+
41
+
42
+ def _ascii_lower(text: str) -> str:
43
+ return re.sub("[A-Z]", lambda m: m.group(0).lower(), text)
44
+
45
+
46
+ def _finding(path: str, keyword: str, message: str) -> Dict[str, str]:
47
+ return {"instancePath": path, "keyword": keyword, "message": message}
48
+
49
+
50
+ def a2a_surface_findings(value: Any, base: str = "/surfaces/a2a") -> List[Dict[str, str]]:
51
+ """`surfaces.a2a` against service-well-known.schema.json at the pin, in ajv's order: each finding's instancePath
52
+ and keyword are the schema error's (test/fixtures/contract/a2a-vectors.json), with a sentence."""
53
+ if not isinstance(value, dict):
54
+ return [_finding(base, "type", "surfaces.a2a is an object {path, protocolVersion}")]
55
+ found = [_finding(base, "required", "surfaces.a2a has no %s" % k) for k in ("path", "protocolVersion") if k not in value]
56
+ found += [_finding(base, "additionalProperties", "surfaces.a2a has no member %s: the card names its interfaces, "
57
+ "bindings and security schemes (DEC-0122)" % k)
58
+ for k in value if k not in ("path", "protocolVersion")]
59
+ if "path" in value:
60
+ path = value["path"]
61
+ if not isinstance(path, str):
62
+ found.append(_finding(base + "/path", "type", "surfaces.a2a.path is a string"))
63
+ else:
64
+ if len(path) > MAX_PATH:
65
+ found.append(_finding(base + "/path", "maxLength", "surfaces.a2a.path is longer than %d characters" % MAX_PATH))
66
+ if not _ORIGIN_PATH.match(path):
67
+ found.append(_finding(base + "/path", "pattern", "surfaces.a2a.path is not a path on the service origin "
68
+ "(one leading /, no whitespace)"))
69
+ if "protocolVersion" in value and not (isinstance(value["protocolVersion"], str) and value["protocolVersion"] == A2A_PROTOCOL_VERSION):
70
+ found.append(_finding(base + "/protocolVersion", "const", "surfaces.a2a.protocolVersion is %s, the A2A version the "
71
+ "contract pins" % A2A_PROTOCOL_VERSION))
72
+ return found
73
+
74
+
75
+ def a2a_surface_problems(value: Any) -> List[str]:
76
+ """The findings of a2a_surface_findings as sentences; empty when the member is valid."""
77
+ return [f["message"] for f in a2a_surface_findings(value)]
78
+
79
+
80
+ def a2a_surface(path: str = A2A_SURFACE_PATH) -> Dict[str, str]:
81
+ """The `surfaces.a2a` member for a service that serves its public card at `path` (DEC-0122 item 1)."""
82
+ surface = {"path": path, "protocolVersion": A2A_PROTOCOL_VERSION}
83
+ problems = a2a_surface_problems(surface)
84
+ if problems:
85
+ raise ServiceError("surfaces.a2a: %s." % "; ".join(problems))
86
+ return surface
87
+
88
+
89
+ def a2a_view(wk: Any) -> Dict[str, Any]:
90
+ """What a host reads of a well-known document's A2A member; never raises. `declared` is whether `surfaces.a2a`
91
+ is present, `valid` whether it is the schema's, `path` the card's path when it is, `findings` the schema's."""
92
+ surfaces = wk.get("surfaces") if isinstance(wk, dict) else None
93
+ if not isinstance(surfaces, dict) or "a2a" not in surfaces:
94
+ return {"declared": False, "valid": False, "path": None, "protocolVersion": None, "findings": []}
95
+ findings = a2a_surface_findings(surfaces["a2a"])
96
+ valid = not findings
97
+ return {"declared": True, "valid": valid, "path": surfaces["a2a"]["path"] if valid else None,
98
+ "protocolVersion": A2A_PROTOCOL_VERSION if valid else None, "findings": findings}
99
+
100
+
101
+ def _card(code: str, path: str, message: str) -> Dict[str, str]:
102
+ return {"code": code, "instancePath": path, "message": message}
103
+
104
+
105
+ def _secrets(card: Dict[str, Any]) -> List[Dict[str, str]]:
106
+ """Every string the card holds that is shaped like a credential, by its place; walked without recursion, in the
107
+ order JavaScript lists keys, so both twins name the same places in the same order."""
108
+ found: List[Dict[str, str]] = []
109
+ stack: List[Any] = [(card, "")]
110
+ while stack:
111
+ node, path = stack.pop()
112
+ if isinstance(node, str):
113
+ if secret_value(node):
114
+ found.append(_card("secret", path, "%s holds a value shaped like a credential: a public card carries no "
115
+ "secret (DEC-0122 item 2)" % (path or "the card")))
116
+ elif isinstance(node, dict):
117
+ stack.extend((node[k], path + _pointer(k)) for k in reversed(js_keys(node)))
118
+ elif isinstance(node, list):
119
+ stack.extend((node[i], path + _pointer(i)) for i in reversed(range(len(node))))
120
+ return found
121
+
122
+
123
+ def _descriptor_schemes(card: Dict[str, Any], header: str) -> List[Dict[str, str]]:
124
+ """D5: a securitySchemes entry that is an API key in the descriptor's own header. With Authorization (the
125
+ default) the card cannot show the token, so nothing is reported."""
126
+ schemes = card.get("securitySchemes")
127
+ if not isinstance(schemes, dict) or not header or _ascii_lower(header) == "authorization":
128
+ return []
129
+ found = []
130
+ for name in js_keys(schemes):
131
+ scheme = schemes[name]
132
+ if not isinstance(scheme, dict):
133
+ continue
134
+ key = scheme.get("apiKeySecurityScheme") # A2A 1.0: one wrapper member per kind
135
+ if isinstance(key, dict):
136
+ location, named = key.get("location"), key.get("name")
137
+ elif scheme.get("type") == "apiKey": # the OpenAPI shape of A2A before 1.0
138
+ location, named = scheme.get("in"), scheme.get("name")
139
+ else:
140
+ continue
141
+ if location == "header" and isinstance(named, str) and _ascii_lower(named) == _ascii_lower(header):
142
+ found.append(_card("descriptor-token-scheme", _pointer("securitySchemes", name),
143
+ "securitySchemes.%s is an API key in %s, the descriptor's token header: an A2A interface "
144
+ "never takes the descriptor's token (DEC-0122 item 3)" % (name, header)))
145
+ return found
146
+
147
+
148
+ def _fabric_extensions(card: Dict[str, Any]) -> List[Dict[str, str]]:
149
+ """D6: an extension under the contract's namespace is not required (A2A 1.0 section 4.4.4): `required` false or
150
+ absent, which A2A reads as false (a protobuf-written card drops a false default); true or a non-boolean is a finding."""
151
+ capabilities = card.get("capabilities")
152
+ extensions = capabilities.get("extensions") if isinstance(capabilities, dict) else None
153
+ if not isinstance(extensions, list):
154
+ return []
155
+ found = []
156
+ for i, extension in enumerate(extensions):
157
+ uri = extension.get("uri") if isinstance(extension, dict) else None
158
+ if not isinstance(uri, str) or _ascii_lower(uri[:len(FABRIC_EXTENSION_PREFIX)]) != FABRIC_EXTENSION_PREFIX:
159
+ continue
160
+ if "required" in extension and extension["required"] is not False:
161
+ found.append(_card("fabric-extension-required", _pointer("capabilities", "extensions", i),
162
+ "the Fabric extension at capabilities.extensions[%d] is required, so a peer that does not "
163
+ "know it cannot call; it carries required: false (DEC-0122 item 4)" % i))
164
+ return found
165
+
166
+
167
+ def _interfaces(card: Dict[str, Any], origin: Optional[str], remote: bool) -> List[Dict[str, str]]:
168
+ """Where the card's interfaces are (DEC-0122 item 2): on a local placement on the service's loopback origin as
169
+ written, on a remote one https; every one A2A 1.0."""
170
+ interfaces = card.get("supportedInterfaces")
171
+ if not isinstance(interfaces, list) or not interfaces:
172
+ return [_card("interfaces-missing", "/supportedInterfaces", "the card names no supportedInterfaces: A2A 1.0 "
173
+ "lists a card's interfaces there")]
174
+ found = []
175
+ for i, interface in enumerate(interfaces):
176
+ place = _pointer("supportedInterfaces", i)
177
+ url = interface.get("url") if isinstance(interface, dict) else None
178
+ if not isinstance(url, str):
179
+ found.append(_card("interface-shape", place, "supportedInterfaces[%d] is not an interface with a url" % i))
180
+ continue
181
+ if remote:
182
+ if not _HTTPS.match(url):
183
+ found.append(_card("interface-scheme", place + "/url", "supportedInterfaces[%d].url is not https: a "
184
+ "loopback or plain-http interface is admitted on a local placement only" % i))
185
+ elif origin and not (url == origin or url.startswith(origin + "/")):
186
+ found.append(_card("interface-origin", place + "/url", "supportedInterfaces[%d].url is not on the service "
187
+ "origin %s, where a peer on this computer reaches it" % (i, origin)))
188
+ if not (isinstance(interface.get("protocolVersion"), str) and interface["protocolVersion"] == A2A_PROTOCOL_VERSION):
189
+ found.append(_card("interface-version", place + "/protocolVersion", "supportedInterfaces[%d] does not expose "
190
+ "A2A %s, the version surfaces.a2a names" % (i, A2A_PROTOCOL_VERSION)))
191
+ return found
192
+
193
+
194
+ def agent_card_findings(card: Any, *, origin: Optional[str] = None, remote: bool = False,
195
+ descriptor_header: Optional[str] = "Authorization") -> List[Dict[str, str]]:
196
+ """What a public Agent Card must not carry or say (DEC-0122 items 2 to 4; decisions D5, D6), each finding
197
+ {code, instancePath, message} naming the place and never the value. `origin` is the service origin the card was
198
+ read on (its interfaces' place on a local placement; omitted, the place is not judged), `remote` the placement,
199
+ `descriptor_header` the descriptor's `auth.header` (default Authorization). It reads the card only: whether an
200
+ interface accepts the token cannot be seen without sending it, which DEC-0122 forbids."""
201
+ if not isinstance(card, dict):
202
+ return [_card("card-shape", "", "the card is not a JSON object")]
203
+ found = [_card("process-member", _pointer(k), "the card carries %s, a member about the running process: a public "
204
+ "card carries none (DEC-0122 item 2)" % k) for k in PROCESS_MEMBERS if k in card]
205
+ found += _secrets(card)
206
+ found += _descriptor_schemes(card, descriptor_header if isinstance(descriptor_header, str) else "")
207
+ found += _fabric_extensions(card)
208
+ found += _interfaces(card, origin, remote)
209
+ return found