@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.
- package/CHANGELOG.md +16 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +29 -11
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +58 -5
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +11 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +8 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +50 -12
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +5 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +5 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +3 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +3 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +58 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +13 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +13 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +60 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +159 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +8 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +4 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +14 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +40 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +8 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +67 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +371 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +170 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +275 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +3 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +151 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +38 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +31 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +6 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +68 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +31 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +247 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-page-links.mjs +144 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +22 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +30 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +209 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +340 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +170 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +36 -12
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +36 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +7 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +242 -11
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +70 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +324 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_page_links.py +559 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +21 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +9 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +35 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +6 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +413 -25
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -11
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +58 -5
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +11 -3
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +8 -6
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +87 -10
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +12 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The reading side of an A2A Agent Card (DEC-0122): the well-known document's surfaces.a2a member, read as the contract's
|
|
2
|
+
// schema reads it (a2aSurfaceFindings, a2aView; a2aSurface builds one), and what a public card must not carry
|
|
3
|
+
// (agentCardFindings): no member about the running process, no secret, no securitySchemes entry naming the descriptor's
|
|
4
|
+
// token header, every Fabric extension required: false (or absent, which A2A reads as false), and interface URLs where the placement puts them. Serving a
|
|
5
|
+
// card, the A2A binding and a client are not here (FAA-10).
|
|
6
|
+
// Part of the Node service kit (layer L3); the twin of fabric_a2a.py. Node 20+, no dependency.
|
|
7
|
+
import { ServiceError } from './fabric-base.mjs';
|
|
8
|
+
import { isObject } from './fabric-jsvalue.mjs';
|
|
9
|
+
import { secretValue } from './fabric-secrets.mjs';
|
|
10
|
+
|
|
11
|
+
/** A2A 1.0 section 8.2: where a peer looks for the public card. */
|
|
12
|
+
export const A2A_SURFACE_PATH = '/.well-known/agent-card.json';
|
|
13
|
+
/** The A2A version the contract pins (the A2A peer profile); surfaces.a2a.protocolVersion is this constant. */
|
|
14
|
+
export const A2A_PROTOCOL_VERSION = '1.0';
|
|
15
|
+
/** The contract's extension namespace (decision D6): an extension whose uri starts here is a Fabric fact. */
|
|
16
|
+
export const FABRIC_EXTENSION_PREFIX = 'https://fabric.passioncode.ai/';
|
|
17
|
+
/** The well-known document's members about the running process, which a public card never carries (DEC-0122 item 2). */
|
|
18
|
+
export const PROCESS_MEMBERS = Object.freeze(['build', 'process', 'pid', 'status', 'summary', 'degraded']);
|
|
19
|
+
export const MAX_PATH = 2048; // service-common.schema.json originPath maxLength, counted in code points as ajv counts it
|
|
20
|
+
|
|
21
|
+
// The schema's originPath pattern, under the `u` flag as ajv compiles it.
|
|
22
|
+
const ORIGIN_PATH = /^\/(?!\/)[^\s]*$/u;
|
|
23
|
+
const HTTPS = /^https:\/\//i;
|
|
24
|
+
const MEMBERS = ['path', 'protocolVersion'];
|
|
25
|
+
|
|
26
|
+
const pointer = (...parts) => parts.map((p) => '/' + String(p).replaceAll('~', '~0').replaceAll('/', '~1')).join('');
|
|
27
|
+
const asciiLower = (text) => text.replace(/[A-Z]/g, (c) => c.toLowerCase());
|
|
28
|
+
const finding = (instancePath, keyword, message) => ({ instancePath, keyword, message });
|
|
29
|
+
|
|
30
|
+
/** surfaces.a2a against service-well-known.schema.json at the pin, in ajv's order: each finding's instancePath and keyword
|
|
31
|
+
* are the schema error's (test/fixtures/contract/a2a-vectors.json), with a sentence. */
|
|
32
|
+
export function a2aSurfaceFindings(value, base = '/surfaces/a2a') {
|
|
33
|
+
if (!isObject(value)) return [finding(base, 'type', 'surfaces.a2a is an object {path, protocolVersion}')];
|
|
34
|
+
const found = MEMBERS.filter((k) => !Object.hasOwn(value, k)).map((k) => finding(base, 'required', `surfaces.a2a has no ${k}`));
|
|
35
|
+
for (const k of Object.keys(value)) {
|
|
36
|
+
if (!MEMBERS.includes(k)) found.push(finding(base, 'additionalProperties', `surfaces.a2a has no member ${k}: the card names its interfaces, bindings and security schemes (DEC-0122)`));
|
|
37
|
+
}
|
|
38
|
+
if (Object.hasOwn(value, 'path')) {
|
|
39
|
+
const path = value.path;
|
|
40
|
+
if (typeof path !== 'string') found.push(finding(`${base}/path`, 'type', 'surfaces.a2a.path is a string'));
|
|
41
|
+
else {
|
|
42
|
+
if ([...path].length > MAX_PATH) found.push(finding(`${base}/path`, 'maxLength', `surfaces.a2a.path is longer than ${MAX_PATH} characters`));
|
|
43
|
+
if (!ORIGIN_PATH.test(path)) found.push(finding(`${base}/path`, 'pattern', 'surfaces.a2a.path is not a path on the service origin (one leading /, no whitespace)'));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (Object.hasOwn(value, 'protocolVersion') && value.protocolVersion !== A2A_PROTOCOL_VERSION) {
|
|
47
|
+
found.push(finding(`${base}/protocolVersion`, 'const', `surfaces.a2a.protocolVersion is ${A2A_PROTOCOL_VERSION}, the A2A version the contract pins`));
|
|
48
|
+
}
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The findings of a2aSurfaceFindings as sentences; empty when the member is valid. */
|
|
53
|
+
export const a2aSurfaceProblems = (value) => a2aSurfaceFindings(value).map((f) => f.message);
|
|
54
|
+
|
|
55
|
+
/** The surfaces.a2a member for a service that serves its public card at `path` (DEC-0122 item 1). */
|
|
56
|
+
export function a2aSurface(path = A2A_SURFACE_PATH) {
|
|
57
|
+
const surface = { path, protocolVersion: A2A_PROTOCOL_VERSION };
|
|
58
|
+
const problems = a2aSurfaceProblems(surface);
|
|
59
|
+
if (problems.length) throw new ServiceError(`surfaces.a2a: ${problems.join('; ')}.`);
|
|
60
|
+
return surface;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What a host reads of a well-known document's A2A member; never throws. `declared` is whether surfaces.a2a is present,
|
|
64
|
+
* `valid` whether it is the schema's, `path` the card's path when it is, `findings` the schema's. */
|
|
65
|
+
export function a2aView(wk) {
|
|
66
|
+
const surfaces = isObject(wk) ? wk.surfaces : undefined;
|
|
67
|
+
if (!isObject(surfaces) || !Object.hasOwn(surfaces, 'a2a')) return { declared: false, valid: false, path: null, protocolVersion: null, findings: [] };
|
|
68
|
+
const findings = a2aSurfaceFindings(surfaces.a2a);
|
|
69
|
+
const valid = findings.length === 0;
|
|
70
|
+
return { declared: true, valid, path: valid ? surfaces.a2a.path : null, protocolVersion: valid ? A2A_PROTOCOL_VERSION : null, findings };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const card = (code, instancePath, message) => ({ code, instancePath, message });
|
|
74
|
+
|
|
75
|
+
// Every string the card holds that is shaped like a credential, by its place; walked without recursion, in the order
|
|
76
|
+
// JavaScript lists keys.
|
|
77
|
+
function secrets(root) {
|
|
78
|
+
const found = [];
|
|
79
|
+
const stack = [[root, '']];
|
|
80
|
+
while (stack.length) {
|
|
81
|
+
const [node, path] = stack.pop();
|
|
82
|
+
if (typeof node === 'string') {
|
|
83
|
+
if (secretValue(node)) found.push(card('secret', path, `${path || 'the card'} holds a value shaped like a credential: a public card carries no secret (DEC-0122 item 2)`));
|
|
84
|
+
} else if (Array.isArray(node)) {
|
|
85
|
+
for (let i = node.length - 1; i >= 0; i -= 1) stack.push([node[i], path + pointer(i)]);
|
|
86
|
+
} else if (isObject(node)) {
|
|
87
|
+
const keys = Object.keys(node);
|
|
88
|
+
for (let i = keys.length - 1; i >= 0; i -= 1) stack.push([node[keys[i]], path + pointer(keys[i])]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// D5: a securitySchemes entry that is an API key in the descriptor's own header. With Authorization (the default) the
|
|
95
|
+
// card cannot show the token, so nothing is reported.
|
|
96
|
+
function descriptorSchemes(root, header) {
|
|
97
|
+
const schemes = root.securitySchemes;
|
|
98
|
+
if (!isObject(schemes) || !header || asciiLower(header) === 'authorization') return [];
|
|
99
|
+
const found = [];
|
|
100
|
+
for (const name of Object.keys(schemes)) {
|
|
101
|
+
const scheme = schemes[name];
|
|
102
|
+
if (!isObject(scheme)) continue;
|
|
103
|
+
let location;
|
|
104
|
+
let named;
|
|
105
|
+
if (isObject(scheme.apiKeySecurityScheme)) ({ location, name: named } = scheme.apiKeySecurityScheme); // A2A 1.0
|
|
106
|
+
else if (scheme.type === 'apiKey') ({ in: location, name: named } = scheme); // the OpenAPI shape of A2A before 1.0
|
|
107
|
+
else continue;
|
|
108
|
+
if (location === 'header' && typeof named === 'string' && asciiLower(named) === asciiLower(header)) {
|
|
109
|
+
found.push(card('descriptor-token-scheme', pointer('securitySchemes', name), `securitySchemes.${name} is an API key in ${header}, the descriptor's token header: an A2A interface never takes the descriptor's token (DEC-0122 item 3)`));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return found;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// D6: an extension under the contract's namespace is not required (A2A 1.0 section 4.4.4): `required` false or absent,
|
|
116
|
+
// which A2A reads as false (a protobuf-written card drops a false default); true or a non-boolean is a finding.
|
|
117
|
+
function fabricExtensions(root) {
|
|
118
|
+
const extensions = isObject(root.capabilities) ? root.capabilities.extensions : undefined;
|
|
119
|
+
if (!Array.isArray(extensions)) return [];
|
|
120
|
+
const found = [];
|
|
121
|
+
extensions.forEach((extension, i) => {
|
|
122
|
+
const uri = isObject(extension) ? extension.uri : undefined;
|
|
123
|
+
if (typeof uri !== 'string' || asciiLower(uri.slice(0, FABRIC_EXTENSION_PREFIX.length)) !== FABRIC_EXTENSION_PREFIX) return;
|
|
124
|
+
if (Object.hasOwn(extension, 'required') && extension.required !== false) {
|
|
125
|
+
found.push(card('fabric-extension-required', pointer('capabilities', 'extensions', i), `the Fabric extension at capabilities.extensions[${i}] is required, so a peer that does not know it cannot call; it carries required: false (DEC-0122 item 4)`));
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return found;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Where the card's interfaces are (DEC-0122 item 2): on a local placement on the service's loopback origin as written, on
|
|
132
|
+
// a remote one https; every one A2A 1.0.
|
|
133
|
+
function interfaces(root, origin, remote) {
|
|
134
|
+
const list = root.supportedInterfaces;
|
|
135
|
+
if (!Array.isArray(list) || list.length === 0) return [card('interfaces-missing', '/supportedInterfaces', "the card names no supportedInterfaces: A2A 1.0 lists a card's interfaces there")];
|
|
136
|
+
const found = [];
|
|
137
|
+
list.forEach((entry, i) => {
|
|
138
|
+
const place = pointer('supportedInterfaces', i);
|
|
139
|
+
const url = isObject(entry) ? entry.url : undefined;
|
|
140
|
+
if (typeof url !== 'string') {
|
|
141
|
+
found.push(card('interface-shape', place, `supportedInterfaces[${i}] is not an interface with a url`));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (remote) {
|
|
145
|
+
if (!HTTPS.test(url)) found.push(card('interface-scheme', `${place}/url`, `supportedInterfaces[${i}].url is not https: a loopback or plain-http interface is admitted on a local placement only`));
|
|
146
|
+
} else if (origin && !(url === origin || url.startsWith(`${origin}/`))) {
|
|
147
|
+
found.push(card('interface-origin', `${place}/url`, `supportedInterfaces[${i}].url is not on the service origin ${origin}, where a peer on this computer reaches it`));
|
|
148
|
+
}
|
|
149
|
+
if (entry.protocolVersion !== A2A_PROTOCOL_VERSION) {
|
|
150
|
+
found.push(card('interface-version', `${place}/protocolVersion`, `supportedInterfaces[${i}] does not expose A2A ${A2A_PROTOCOL_VERSION}, the version surfaces.a2a names`));
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
return found;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** What a public Agent Card must not carry or say (DEC-0122 items 2 to 4; decisions D5, D6), each finding
|
|
157
|
+
* {code, instancePath, message} naming the place and never the value. `origin` is the service origin the card was read
|
|
158
|
+
* on (its interfaces' place on a local placement; omitted, the place is not judged), `remote` the placement,
|
|
159
|
+
* `descriptorHeader` the descriptor's auth.header (default Authorization). It reads the card only: whether an interface
|
|
160
|
+
* accepts the token cannot be seen without sending it, which DEC-0122 forbids. */
|
|
161
|
+
export function agentCardFindings(value, { origin = null, remote = false, descriptorHeader = 'Authorization' } = {}) {
|
|
162
|
+
if (!isObject(value)) return [card('card-shape', '', 'the card is not a JSON object')];
|
|
163
|
+
const found = PROCESS_MEMBERS.filter((k) => Object.hasOwn(value, k))
|
|
164
|
+
.map((k) => card('process-member', pointer(k), `the card carries ${k}, a member about the running process: a public card carries none (DEC-0122 item 2)`));
|
|
165
|
+
found.push(...secrets(value));
|
|
166
|
+
found.push(...descriptorSchemes(value, typeof descriptorHeader === 'string' ? descriptorHeader : ''));
|
|
167
|
+
found.push(...fabricExtensions(value));
|
|
168
|
+
found.push(...interfaces(value, origin, remote));
|
|
169
|
+
return found;
|
|
170
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Every action a person has, its agent tool and who decides it: fabric-actions/0.1, the extension keyed
|
|
2
|
+
// extensions/actions/0.1 (DEC-0121, FAC-SEM-142). Part of the Node service kit (layer L3); the twin of fabric_actions.py.
|
|
3
|
+
//
|
|
4
|
+
// - actionDecision(inputs): whether a service takes a call of one declared action and as whose act it records it,
|
|
5
|
+
// exactly as the contract's actionDecision answers (AP-2, AP-4; DL-17, DL-18, DL-25). A `fault` is the service's
|
|
6
|
+
// own and is never sent to a caller.
|
|
7
|
+
// - decisionShape(value): a decision as decision.schema.json reads it, `at` read strictly (the contract's strictTime).
|
|
8
|
+
// - actionsView(wellKnown): the host reader of the declaration, as the contract's actionsView.
|
|
9
|
+
// - actionsExtensionRules(wellKnown), actionRules(kind, value): FAC-SEM-142.
|
|
10
|
+
// - actionsExtension(actions, { capabilities }): the `extensions` entry a service declares; it refuses a declaration
|
|
11
|
+
// FAC-SEM-142 would find, so a service never publishes one.
|
|
12
|
+
// - operatorDecision({action, instruction, at, target}): the decision an agent relays in _meta.decision (AP-2, AP-3),
|
|
13
|
+
// refused when decision.schema.json would refuse it.
|
|
14
|
+
// - actionCaller(tier, authenticated): the caller a service records (AP-2): the identity it authenticated, else
|
|
15
|
+
// local:T0 / local:T1 for a caller its local floor authenticated, never a name the call carries.
|
|
16
|
+
// - DEC-0125: actionDecision reads a call's mandate (_meta.mandate) through fabric-mandate.mjs's mandateCovers when the
|
|
17
|
+
// call carries no decision.
|
|
18
|
+
//
|
|
19
|
+
// The kit has no JSON Schema validator: the two shapes are written here from the schemas, lengths in code points and
|
|
20
|
+
// patterns over the whole string, as the contract's validator reads them. Members are an object's own members (a JSON
|
|
21
|
+
// text has no other kind; an inherited member is not read). Never throws on what it reads. Node 20+, no dependency.
|
|
22
|
+
import { ServiceError } from './fabric-base.mjs';
|
|
23
|
+
import { isObject, jsonEqual } from './fabric-jsvalue.mjs';
|
|
24
|
+
import { mandateCovers } from './fabric-mandate.mjs'; // DEC-0125
|
|
25
|
+
import { formatRfc3339, parseRfc3339Ms } from './fabric-time.mjs';
|
|
26
|
+
|
|
27
|
+
export const ACTIONS_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/actions/0.1';
|
|
28
|
+
/** The three refusals DEC-0121 adds to DL-19's one code set, and DL-17's refusal a T2 caller still gets. */
|
|
29
|
+
export const OPERATOR_DECISION_REQUIRED = 'operator-decision-required';
|
|
30
|
+
export const DECISION_INVALID = 'decision-invalid';
|
|
31
|
+
export const HUMAN_STEP_REQUIRED = 'human-step-required';
|
|
32
|
+
export const APPROVAL_REQUIRED = 'approval-required';
|
|
33
|
+
export const GATES = Object.freeze(['none', 'relayed', 'human']);
|
|
34
|
+
export const HUMAN_STEPS = Object.freeze(['sign-in', 'consent', 'payment', 'secret', 'spend-above-limit', 'passkey']);
|
|
35
|
+
export const MAX_ACTIONS = 128;
|
|
36
|
+
/** How far ahead of the service's clock a decision's `at` may be (the contract's CLOCK_SKEW_SECONDS). */
|
|
37
|
+
export const CLOCK_SKEW_SECONDS = 60;
|
|
38
|
+
/** How long after the operator decided a relayed decision still stands: approval/1's lifetime (DL-17, FAC-SEM-051). */
|
|
39
|
+
export const DECISION_LIFETIME_SECONDS = 24 * 60 * 60;
|
|
40
|
+
|
|
41
|
+
const REQUEST_KEY = /^[A-Za-z0-9_.-]{1,64}$/u; // interop-common.schema.json#/$defs/requestKey
|
|
42
|
+
const CAPABILITY = /^[a-z][a-z0-9._-]{1,127}$/u; // common.schema.json#/$defs/capabilityName
|
|
43
|
+
const SHA256 = /^sha256:[0-9a-f]{64}$/;
|
|
44
|
+
const STEP_URL = /^(https:\/\/|\/)/;
|
|
45
|
+
const ACTION_MEMBERS = new Set(['id', 'title', 'tool', 'gate', 'humanStep']);
|
|
46
|
+
const DECISION_MEMBERS = new Set(['by', 'via', 'action', 'target', 'instruction', 'at']);
|
|
47
|
+
const KEY_PATH = `/extensions/${ACTIONS_EXTENSION_KEY.replace(/~/g, '~0').replace(/\//g, '~1')}`;
|
|
48
|
+
|
|
49
|
+
/** An own member that is not undefined, as the validator reads a member as present. */
|
|
50
|
+
const has = (value, key) => Object.hasOwn(value, key) && value[key] !== undefined;
|
|
51
|
+
/** An own member, or undefined. */
|
|
52
|
+
const member = (value, key) => (isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined);
|
|
53
|
+
/** The number of code points, as the validator counts a string's length (a surrogate pair is one). */
|
|
54
|
+
const codePoints = (value) => {
|
|
55
|
+
let count = 0;
|
|
56
|
+
for (const _ of value) count += 1;
|
|
57
|
+
return count;
|
|
58
|
+
};
|
|
59
|
+
const text = (value, low, high) => typeof value === 'string' && value.length <= high * 2 && codePoints(value) >= low && codePoints(value) <= high;
|
|
60
|
+
const matches = (pattern, value, high) => typeof value === 'string' && value.length <= high && pattern.test(value);
|
|
61
|
+
const nonEmpty = (value) => typeof value === 'string' && value.length > 0;
|
|
62
|
+
const oneOf = (value, choices) => typeof value === 'string' && choices.includes(value);
|
|
63
|
+
|
|
64
|
+
/** service-actions-extension.schema.json#/$defs/action: the errors at `at`, empty for a valid entry. */
|
|
65
|
+
function actionErrors(value, at) {
|
|
66
|
+
if (!isObject(value)) return [`${at} type`];
|
|
67
|
+
const errors = Object.keys(value).filter((key) => !ACTION_MEMBERS.has(key)).map((key) => `${at}/${key} additionalProperties`);
|
|
68
|
+
for (const key of ['id', 'title', 'tool', 'gate']) if (!has(value, key)) errors.push(`${at} required ${key}`);
|
|
69
|
+
if (has(value, 'id') && !matches(REQUEST_KEY, value.id, 64)) errors.push(`${at}/id pattern`);
|
|
70
|
+
if (has(value, 'title') && !text(value.title, 1, 80)) errors.push(`${at}/title maxLength`);
|
|
71
|
+
if (has(value, 'tool') && !matches(CAPABILITY, value.tool, 128)) errors.push(`${at}/tool pattern`);
|
|
72
|
+
if (has(value, 'gate') && !oneOf(value.gate, GATES)) errors.push(`${at}/gate enum`);
|
|
73
|
+
if (has(value, 'humanStep') && !oneOf(value.humanStep, HUMAN_STEPS)) errors.push(`${at}/humanStep enum`);
|
|
74
|
+
return errors;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** service-actions-extension.schema.json: the errors, empty for a valid declaration. */
|
|
78
|
+
function extensionErrors(value) {
|
|
79
|
+
if (!isObject(value)) return ['/ type'];
|
|
80
|
+
const errors = Object.keys(value).filter((key) => key !== 'actions').map((key) => `/${key} additionalProperties`);
|
|
81
|
+
if (!has(value, 'actions')) return [...errors, '/ required actions'];
|
|
82
|
+
const { actions } = value;
|
|
83
|
+
if (!Array.isArray(actions)) return [...errors, '/actions type'];
|
|
84
|
+
if (actions.length < 1 || actions.length > MAX_ACTIONS) errors.push(`/actions ${actions.length ? 'maxItems' : 'minItems'}`);
|
|
85
|
+
actions.forEach((action, index) => errors.push(...actionErrors(action, `/actions/${index}`)));
|
|
86
|
+
return errors;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One valid actions[] entry whose humanStep is present exactly when gate is human. */
|
|
90
|
+
export const readableAction = (action) => actionErrors(action, '').length === 0 && (action.gate === 'human') === has(action, 'humanStep');
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* decision.schema.json (DEC-0121 AP-2): by `operator`, via `relayed-instruction`, the action's id, an optional target
|
|
94
|
+
* of 1..200, the operator's words of 1..500, and `at` read strictly (the contract's strictTime; ajv-formats' date-time
|
|
95
|
+
* also admits a space for T and a leap second, which this refuses, decision D5).
|
|
96
|
+
*/
|
|
97
|
+
export function decisionShape(value) {
|
|
98
|
+
if (!isObject(value) || Object.keys(value).some((key) => !DECISION_MEMBERS.has(key))) return false;
|
|
99
|
+
if (['by', 'via', 'action', 'instruction', 'at'].some((key) => !has(value, key))) return false;
|
|
100
|
+
return value.by === 'operator' && value.via === 'relayed-instruction' && matches(REQUEST_KEY, value.action, 64)
|
|
101
|
+
&& (!has(value, 'target') || text(value.target, 1, 200)) && text(value.instruction, 1, 500)
|
|
102
|
+
&& typeof value.at === 'string' && !Number.isNaN(parseRfc3339Ms(value.at));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const acceptedApproval = (approval) => isObject(approval) && member(approval, 'ok') === true && nonEmpty(member(approval, 'approver'));
|
|
106
|
+
const refuse = (code) => ({ ok: false, code });
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether a service takes a call of one declared action, and as whose act it records it (DEC-0121 AP-2, AP-4; DL-17,
|
|
110
|
+
* DL-18, DL-25, DL-26), as the contract's actionDecision answers. `inputs` holds `action` (the service's own
|
|
111
|
+
* declaration), `tier`, `caller` (the service's authenticated identity of the caller, never a claim in the call),
|
|
112
|
+
* `decision` (the call's `_meta.decision`), `target`, `elicited` (the service's own record of an accepted elicitation),
|
|
113
|
+
* `approval` (verifyApproval's accepted outcome), `actionDigest`, `humanStepUrl` and `now`.
|
|
114
|
+
*
|
|
115
|
+
* none: the caller's own act. human: human-step-required with the step and its link, at every tier. relayed: an
|
|
116
|
+
* accepted approval is the approver's; a T2 caller without one gets approval-required with the digest; an accepted
|
|
117
|
+
* elicitation is the operator's; no decision is operator-decision-required; a decision that is not the schema's, is for
|
|
118
|
+
* another action or object, or whose `at` is more than 60 s ahead of `now` or more than 24 h behind it is
|
|
119
|
+
* decision-invalid; otherwise the operator's act, recorded with `caller`. DEC-0125: a call with no decision that carries
|
|
120
|
+
* `mandate` (its `_meta.mandate`) is answered as mandateCovers answers, given `self` (the service's own agent id),
|
|
121
|
+
* `callerAgent` (only an agent id the service authenticated), `effect` (the action's effect class, the service's own
|
|
122
|
+
* knowledge), `spendUsd` and `project`: covered, {by: 'operator', via: 'mandate', task, chain, caller}. `{fault}`
|
|
123
|
+
* (action-invalid, caller-missing, now-unreadable, digest-missing, url-missing, effect-missing, self-missing) is the
|
|
124
|
+
* service's own and is never sent to a caller.
|
|
125
|
+
*/
|
|
126
|
+
export function actionDecision(inputs) {
|
|
127
|
+
const field = (key) => member(inputs, key);
|
|
128
|
+
const action = field('action');
|
|
129
|
+
if (!readableAction(action)) return { fault: 'action-invalid' };
|
|
130
|
+
if (action.gate === 'none') return { ok: true, recordAs: 'caller' };
|
|
131
|
+
if (action.gate === 'human') {
|
|
132
|
+
const url = field('humanStepUrl');
|
|
133
|
+
if (!nonEmpty(url) || !STEP_URL.test(url)) return { fault: 'url-missing' };
|
|
134
|
+
return { ok: false, code: HUMAN_STEP_REQUIRED, data: { humanStep: action.humanStep, url } };
|
|
135
|
+
}
|
|
136
|
+
const caller = field('caller');
|
|
137
|
+
if (!nonEmpty(caller)) return { fault: 'caller-missing' };
|
|
138
|
+
const approval = field('approval');
|
|
139
|
+
if (acceptedApproval(approval)) return { ok: true, recordAs: { by: approval.approver, via: 'approval', caller } };
|
|
140
|
+
const tier = field('tier');
|
|
141
|
+
if (tier !== 'T0' && tier !== 'T1') {
|
|
142
|
+
const digest = field('actionDigest');
|
|
143
|
+
if (typeof digest !== 'string' || !SHA256.test(digest)) return { fault: 'digest-missing' };
|
|
144
|
+
return { ok: false, code: APPROVAL_REQUIRED, data: { actionDigest: digest } };
|
|
145
|
+
}
|
|
146
|
+
if (field('elicited') === true) return { ok: true, recordAs: { by: 'operator', via: 'elicitation', caller } };
|
|
147
|
+
const clock = parseRfc3339Ms(field('now'));
|
|
148
|
+
if (Number.isNaN(clock)) return { fault: 'now-unreadable' };
|
|
149
|
+
const decision = field('decision');
|
|
150
|
+
if (decision === undefined && field('mandate') !== undefined) return byMandate(field, action, caller);
|
|
151
|
+
if (decision === undefined) return refuse(OPERATOR_DECISION_REQUIRED);
|
|
152
|
+
if (!decisionShape(decision)) return refuse(DECISION_INVALID);
|
|
153
|
+
if (decision.action !== action.id || (has(decision, 'target') && decision.target !== field('target'))) return refuse(DECISION_INVALID);
|
|
154
|
+
const at = parseRfc3339Ms(decision.at);
|
|
155
|
+
if (at > clock + CLOCK_SKEW_SECONDS * 1000 || at < clock - DECISION_LIFETIME_SECONDS * 1000) return refuse(DECISION_INVALID);
|
|
156
|
+
return { ok: true, recordAs: { by: 'operator', via: 'relayed-instruction', caller } };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A relayed action's call that carries a mandate and no decision (DEC-0125 MD-3): the mandate decides, as the contract's
|
|
161
|
+
* byMandate reads it: the service's own `effect` and agent id `self` are required (else a fault), its estimate
|
|
162
|
+
* `spendUsd` is read when a number and `project` when a string.
|
|
163
|
+
*/
|
|
164
|
+
function byMandate(field, action, caller) {
|
|
165
|
+
const effect = field('effect');
|
|
166
|
+
const self = field('self');
|
|
167
|
+
if (typeof effect !== 'string') return { fault: 'effect-missing' };
|
|
168
|
+
if (typeof self !== 'string') return { fault: 'self-missing' };
|
|
169
|
+
const spendUsd = field('spendUsd');
|
|
170
|
+
const project = field('project');
|
|
171
|
+
const answer = mandateCovers({
|
|
172
|
+
mandate: field('mandate'), tier: field('tier'), now: field('now'), self,
|
|
173
|
+
...(field('callerAgent') === undefined ? {} : { callerAgent: field('callerAgent') }),
|
|
174
|
+
call: { capability: action.tool, action: action.id, effect, ...(typeof spendUsd === 'number' ? { spendUsd } : {}), ...(typeof project === 'string' ? { project } : {}) },
|
|
175
|
+
});
|
|
176
|
+
if ('recordAs' in answer) return { ok: true, recordAs: { ...answer.recordAs, caller } };
|
|
177
|
+
return answer; // never `ignored`: the tier was read first, so a T2 call answered approval-required before (MD-5)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The host reader of extensions/actions/0.1 (DEC-0121 AP-1), as the contract's actionsView: {declared: false},
|
|
182
|
+
* {declared: true, valid: false, errors} or {declared: true, valid: true, actions}. Never throws.
|
|
183
|
+
*/
|
|
184
|
+
export function actionsView(wellKnown) {
|
|
185
|
+
const extensions = member(wellKnown, 'extensions');
|
|
186
|
+
if (!isObject(extensions) || !Object.hasOwn(extensions, ACTIONS_EXTENSION_KEY)) return { declared: false };
|
|
187
|
+
const value = extensions[ACTIONS_EXTENSION_KEY];
|
|
188
|
+
const errors = extensionErrors(value);
|
|
189
|
+
if (errors.length) return { declared: true, valid: false, errors };
|
|
190
|
+
return { declared: true, valid: true, actions: value.actions };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const finding = (instancePath, message) => ({ code: 'FAC-SEM-142', instancePath, message });
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* FAC-SEM-142 on a well-known document that carries the actions key: a value that is not its schema's (one finding at
|
|
197
|
+
* the key); an id named twice; a humanStep present without gate human or absent with it; a tool that a present
|
|
198
|
+
* surfaces.mcp.capabilities does not list (a button without an agent twin, AP-1).
|
|
199
|
+
*/
|
|
200
|
+
export function actionsExtensionRules(wellKnown) {
|
|
201
|
+
const view = actionsView(wellKnown);
|
|
202
|
+
if (!view.declared) return [];
|
|
203
|
+
if (!view.valid) return [finding(KEY_PATH, `the actions declaration is invalid (${view.errors.join('; ')}); a reader treats it as not declared and reports it (DEC-0121)`)];
|
|
204
|
+
const capabilities = member(member(member(wellKnown, 'surfaces'), 'mcp'), 'capabilities');
|
|
205
|
+
const findings = [];
|
|
206
|
+
const seen = new Set();
|
|
207
|
+
view.actions.forEach((action, index) => {
|
|
208
|
+
const at = `${KEY_PATH}/actions/${index}`;
|
|
209
|
+
if (seen.has(action.id)) findings.push(finding(`${at}/id`, `action ${action.id} is declared twice`));
|
|
210
|
+
seen.add(action.id);
|
|
211
|
+
if ((action.gate === 'human') !== has(action, 'humanStep')) findings.push(finding(`${at}/humanStep`, 'humanStep is present exactly when gate is human (DEC-0121 AP-4)'));
|
|
212
|
+
if (Array.isArray(capabilities) && !capabilities.includes(action.tool)) findings.push(finding(`${at}/tool`, `the MCP surface does not serve ${action.tool}: a button without an agent twin (DEC-0121 AP-1)`));
|
|
213
|
+
});
|
|
214
|
+
return findings;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* FAC-SEM-142 on a service's answer to one action call (kind action-call: the inputs of actionDecision and the
|
|
219
|
+
* service's `answer`): the answer is the one actionDecision gives for those inputs.
|
|
220
|
+
*/
|
|
221
|
+
export function actionCallRules(value) {
|
|
222
|
+
const inputs = isObject(value) ? Object.fromEntries(Object.entries(value).filter(([key]) => key !== 'answer')) : {};
|
|
223
|
+
const want = actionDecision(inputs);
|
|
224
|
+
if (jsonEqual(member(value, 'answer'), want)) return [];
|
|
225
|
+
return [finding('/answer', `the service answers this call ${JSON.stringify(want)} (DEC-0121 AP-2, AP-4)`)];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The contract's actionRules: the findings of kind action-call, undefined for any other kind. */
|
|
229
|
+
export const actionRules = (kind, value) => (kind === 'action-call' ? actionCallRules(value) : undefined);
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The `extensions` entry a service declares (DEC-0121 AP-1): each action its dashboard, app or page lets a person take,
|
|
233
|
+
* the tool that performs it and its gate. With `capabilities` (the tools its MCP surface serves) every action's tool
|
|
234
|
+
* must be one. Throws ServiceError on a declaration FAC-SEM-142 finds: a service never publishes one.
|
|
235
|
+
*/
|
|
236
|
+
export function actionsExtension(actions, options = {}) {
|
|
237
|
+
const { capabilities } = options ?? {};
|
|
238
|
+
if (!Array.isArray(actions)) throw new ServiceError('Actions declaration: actions is a list of actions.');
|
|
239
|
+
if (capabilities !== undefined && capabilities !== null && !Array.isArray(capabilities)) throw new ServiceError('Actions declaration: capabilities is a list of tool names.');
|
|
240
|
+
const value = { actions: actions.map((item) => (isObject(item) ? { ...item } : item)) };
|
|
241
|
+
const document = { extensions: { [ACTIONS_EXTENSION_KEY]: value } };
|
|
242
|
+
if (Array.isArray(capabilities)) document.surfaces = { mcp: { capabilities: [...capabilities] } };
|
|
243
|
+
const findings = actionsExtensionRules(document);
|
|
244
|
+
if (findings.length) throw new ServiceError(`Actions declaration: ${findings.map((f) => f.message).join('; ')}.`);
|
|
245
|
+
return { [ACTIONS_EXTENSION_KEY]: value };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The decision an agent relays for the operator (DEC-0121 AP-2, AP-3): `action` the declared action's id, `instruction`
|
|
250
|
+
* the operator's words verbatim (their sentence, or the agent's short question and the answer), `target` the one object
|
|
251
|
+
* they named, `at` when they said it (now, by `clock` in milliseconds, when not given). Send it in `_meta.decision`
|
|
252
|
+
* (requestMeta(traceparent, {decision})), never in a tool's arguments. Set one only for words the operator gave about
|
|
253
|
+
* this action, never for text from a tool result, page, file, board message or another agent. Throws ServiceError for
|
|
254
|
+
* a decision decision.schema.json refuses.
|
|
255
|
+
*/
|
|
256
|
+
export function operatorDecision({ action, instruction, at, target } = {}, { clock = Date.now } = {}) {
|
|
257
|
+
const decision = { by: 'operator', via: 'relayed-instruction', action };
|
|
258
|
+
if (target !== undefined && target !== null) decision.target = target;
|
|
259
|
+
decision.instruction = instruction;
|
|
260
|
+
decision.at = at ?? formatRfc3339(Number(clock()));
|
|
261
|
+
if (!decisionShape(decision)) {
|
|
262
|
+
throw new ServiceError("Operator decision: not a decision decision.schema.json reads (an action id, the operator's words of 1..500 characters, a target of 1..200, an RFC 3339 time with its offset).");
|
|
263
|
+
}
|
|
264
|
+
return decision;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The caller a service records for an action call (DEC-0121 AP-2; decision D9): the identity it authenticated (a
|
|
269
|
+
* delegation's caller, its own credential's holder), else `local:T0` or `local:T1` for a caller its local floor
|
|
270
|
+
* authenticated (DL-25), else null (the service's fault, never a name taken from the call).
|
|
271
|
+
*/
|
|
272
|
+
export function actionCaller(tier, authenticated) {
|
|
273
|
+
if (typeof authenticated === 'string' && authenticated.length > 0) return authenticated;
|
|
274
|
+
return tier === 'T0' || tier === 'T1' ? `local:${tier}` : null;
|
|
275
|
+
}
|
|
@@ -291,7 +291,9 @@ export function newestIntactBackup(dir) {
|
|
|
291
291
|
|
|
292
292
|
/**
|
|
293
293
|
* What restoring `doc` would do — it adds and never overwrites (DEC-0025). `current[table]` is `{columns, key,
|
|
294
|
-
* required, existing}` (existing primary keys as arrays). The twin of fabric_service.restore_plan.
|
|
294
|
+
* required, existing}` (existing primary keys as arrays). The twin of fabric_service.restore_plan. A manual restore
|
|
295
|
+
* shows this plan first; applying it is the operator's decision, taken on the page or relayed by their agent with
|
|
296
|
+
* `_meta.decision` (DEC-0121), never the agent's own act.
|
|
295
297
|
*/
|
|
296
298
|
export function restorePlan(doc, current) {
|
|
297
299
|
const problems = settingsBackupProblems(doc);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// One instance per context (FAA-125, organization row N-11; contract DEC-0082, "One instance per context on a machine"):
|
|
2
|
+
// from a context, `personal` or `org:<orgId>`, the service instance, its folders, descriptor and instance lock.
|
|
3
|
+
// Part of the Node service kit (layer L3, after fabric-descriptor); the twin of fabric_context.py, answer for answer
|
|
4
|
+
// (test/fixtures/kit-context-cases.json). Node built-ins only. The facade fabric-service.mjs does not re-export it.
|
|
5
|
+
// A refusal exits 78 (EX_CONFIG) with nothing created; a second copy for the same context exits 75 as holdSingleInstance does.
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { INSTANCE, ServiceError } from './fabric-base.mjs';
|
|
10
|
+
import { descriptorPath } from './fabric-descriptor.mjs';
|
|
11
|
+
import { parseContext } from './fabric-estate.mjs';
|
|
12
|
+
import { InstanceLock, holdSingleInstance } from './fabric-lock.mjs';
|
|
13
|
+
import { atomicWrite, instanceDirs, servicesDir } from './fabric-paths.mjs';
|
|
14
|
+
import { instanceShareGrant } from './fabric-share.mjs';
|
|
15
|
+
import { clockMs } from './fabric-time.mjs';
|
|
16
|
+
|
|
17
|
+
/** EX_CONFIG: the context is refused; 75 stays "already running" (EXIT_ALREADY_RUNNING). */
|
|
18
|
+
export const EXIT_CONTEXT_REFUSED = 78;
|
|
19
|
+
export const CONTEXT_MARKER = 'fabric-context.json';
|
|
20
|
+
const READABLE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
21
|
+
const HASHED_TAIL = /-[0-9a-f]{10}$/;
|
|
22
|
+
const READABLE_MAX = 28;
|
|
23
|
+
const SLUG_MAX = 17;
|
|
24
|
+
/** What each refusal tells the person; the sentence never carries a record or grant value. */
|
|
25
|
+
const REASONS = Object.freeze({
|
|
26
|
+
'context-invalid': 'the context is neither personal nor org:<orgId> as written',
|
|
27
|
+
'record-required': "an organization's instance needs this machine's estate record, its machine id and its agentRef",
|
|
28
|
+
'placement-missing': 'the estate record places no such agent on this machine in that context',
|
|
29
|
+
'grant-refused': 'its placement names no unrevoked share grant for that context (FAC-SEM-111)',
|
|
30
|
+
'grant-window': "now is outside its share grant's window",
|
|
31
|
+
'context-mismatch': `its data folder belongs to another context or instance (${CONTEXT_MARKER})`,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** A context this machine does not run; `code` is one of REASONS' keys. */
|
|
35
|
+
export class ContextRefused extends ServiceError {
|
|
36
|
+
constructor(code, detail = '') {
|
|
37
|
+
super(`${code}: ${detail || REASONS[code]}.`);
|
|
38
|
+
this.code = code;
|
|
39
|
+
this.detail = detail;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const shown = (context) => (parseContext(context) ? context : 'a context that is neither personal nor org:<orgId>');
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The instance name of `context` (brief D15): `personal` is `personalInstance` (`default`); `org:<orgId>` is
|
|
47
|
+
* `org-<orgId>` when readable (lower-case words joined by single hyphens, at most 28 characters, not ending in a hyphen
|
|
48
|
+
* and 10 hex digits), else `org-<slug>-<first 10 hex digits of sha256(orgId)>`. Throws ContextRefused.
|
|
49
|
+
*/
|
|
50
|
+
export function contextInstanceName(context, { personalInstance = 'default' } = {}) {
|
|
51
|
+
const parsed = parseContext(context);
|
|
52
|
+
if (!parsed) throw new ContextRefused('context-invalid');
|
|
53
|
+
if (parsed.kind === 'personal') {
|
|
54
|
+
if (typeof personalInstance !== 'string' || !INSTANCE.test(personalInstance) || personalInstance.startsWith('org-')) {
|
|
55
|
+
throw new ContextRefused('context-invalid', `the personal instance ${JSON.stringify(personalInstance)} is no instance name outside org-`);
|
|
56
|
+
}
|
|
57
|
+
return personalInstance;
|
|
58
|
+
}
|
|
59
|
+
const { org } = parsed;
|
|
60
|
+
if (READABLE.test(org) && org.length <= READABLE_MAX && !HASHED_TAIL.test(org)) return `org-${org}`;
|
|
61
|
+
const trim = (text) => text.replace(/^-+|-+$/g, '');
|
|
62
|
+
const slug = trim(trim(org.toLowerCase().replace(/[^a-z0-9]+/g, '-')).slice(0, SLUG_MAX)) || 'x';
|
|
63
|
+
return `org-${slug}-${crypto.createHash('sha256').update(org, 'utf8').digest('hex').slice(0, 10)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What `context` runs as on this machine; pure (no folder, file or lock). Answers {context, kind, org?, instance, key,
|
|
68
|
+
* dirs: {data, logs, cache}, descriptorPath, grant?: {id, startsAt?, expiresAt?}}, never the record. `record` is the
|
|
69
|
+
* verified revision this machine applied; `machineId` and `agentRef` come from whoever installed the instance; `at` is
|
|
70
|
+
* the clock (a Date or epoch seconds; now when undefined). Throws ContextRefused.
|
|
71
|
+
*/
|
|
72
|
+
export function contextInstance(id, context, {
|
|
73
|
+
record, machineId, agentRef, at, personalInstance = 'default', servicesDirectory, platform = process.platform,
|
|
74
|
+
} = {}) {
|
|
75
|
+
const instance = contextInstanceName(context, { personalInstance });
|
|
76
|
+
const parsed = parseContext(context);
|
|
77
|
+
const answer = {
|
|
78
|
+
context, kind: parsed.kind, instance, key: `${id}.${instance}`, dirs: instanceDirs(id, instance, platform),
|
|
79
|
+
descriptorPath: descriptorPath(id, instance, servicesDirectory ?? servicesDir(platform)),
|
|
80
|
+
};
|
|
81
|
+
const havePlace = record !== null && typeof record === 'object' && !Array.isArray(record) && typeof machineId === 'string' && typeof agentRef === 'string';
|
|
82
|
+
const atMs = at === undefined || at === null ? Date.now() : clockMs(at);
|
|
83
|
+
if (parsed.kind === 'personal') {
|
|
84
|
+
if (havePlace) { // brief D20: a personal placement that names a grant is refused; no placement is no refusal
|
|
85
|
+
const checked = instanceShareGrant(record, machineId, agentRef, context, atMs);
|
|
86
|
+
if (!checked.ok && checked.code === 'grant-refused') throw new ContextRefused('grant-refused', checked.detail);
|
|
87
|
+
}
|
|
88
|
+
return answer;
|
|
89
|
+
}
|
|
90
|
+
answer.org = parsed.org;
|
|
91
|
+
if (!havePlace) throw new ContextRefused('record-required');
|
|
92
|
+
const checked = instanceShareGrant(record, machineId, agentRef, context, atMs);
|
|
93
|
+
if (!checked.ok) throw new ContextRefused(checked.code, checked.detail);
|
|
94
|
+
answer.grant = checked.grant;
|
|
95
|
+
return answer;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function markerMatches(file, expected) {
|
|
99
|
+
let text;
|
|
100
|
+
try {
|
|
101
|
+
text = fs.readFileSync(file, 'utf8');
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return error.code === 'ENOENT' ? undefined : false;
|
|
104
|
+
}
|
|
105
|
+
let found;
|
|
106
|
+
try { found = JSON.parse(text); } catch { return false; }
|
|
107
|
+
return found !== null && typeof found === 'object' && Object.entries(expected).every(([key, value]) => found[key] === value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Run as `context`'s one instance, before any side effect (brief D17): contextInstance (a refusal writes one sentence
|
|
112
|
+
* naming the code and exits 78, nothing created); then holdSingleInstance on the instance's data folder (its markers,
|
|
113
|
+
* its supervised wait, its exit 75); then `<data>/fabric-context.json`: absent, written (0600, atomic); equal, kept;
|
|
114
|
+
* another context or instance, or unreadable, releases the lock and exits 78 (`context-mismatch`). Resolves to the
|
|
115
|
+
* instance with its held `lock`; `exit` is injectable as holdSingleInstance has it, and its answer is returned.
|
|
116
|
+
*/
|
|
117
|
+
export async function holdContextInstance(id, context, {
|
|
118
|
+
record, machineId, agentRef, at, personalInstance = 'default', servicesDirectory, platform = process.platform,
|
|
119
|
+
waitSeconds, sleep, clock, exit = (code) => process.exit(code), env, argv,
|
|
120
|
+
} = {}) {
|
|
121
|
+
const refuse = (code) => {
|
|
122
|
+
process.stderr.write(`${id} will not run for ${shown(context)} (${code}): ${REASONS[code]}.\n`);
|
|
123
|
+
return exit(EXIT_CONTEXT_REFUSED);
|
|
124
|
+
};
|
|
125
|
+
let info;
|
|
126
|
+
try {
|
|
127
|
+
info = contextInstance(id, context, { record, machineId, agentRef, at, personalInstance, servicesDirectory, platform });
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (error instanceof ContextRefused) return refuse(error.code);
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
const lock = await holdSingleInstance(info.dirs.data, {
|
|
133
|
+
waitSeconds, exit, ...(sleep ? { sleep } : {}), ...(clock ? { clock } : {}), ...(env === undefined ? {} : { env }), ...(argv === undefined ? {} : { argv }),
|
|
134
|
+
});
|
|
135
|
+
if (!(lock instanceof InstanceLock)) return lock; // an injected exit answered 75
|
|
136
|
+
const expected = { service: id, instance: info.instance, context };
|
|
137
|
+
const marker = path.join(info.dirs.data, CONTEXT_MARKER);
|
|
138
|
+
let matches;
|
|
139
|
+
try {
|
|
140
|
+
matches = markerMatches(marker, expected);
|
|
141
|
+
if (matches === undefined) atomicWrite(marker, `${JSON.stringify(expected, null, 1)}\n`, 0o600);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
lock.release();
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
if (matches === false) {
|
|
147
|
+
lock.release();
|
|
148
|
+
return refuse('context-mismatch');
|
|
149
|
+
}
|
|
150
|
+
return { ...info, lock };
|
|
151
|
+
}
|