@passioncode-ai/fabric-agent-adapter 0.60.0 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +246 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +4 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +11 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +19 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +58 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +23 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +6 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +18 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +33 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +10 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +117 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +24 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +33 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +14 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +24 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +16 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +146 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +20 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +55 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +33 -25
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +19 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +23 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +17 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +86 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +217 -29
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +42 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +23 -23
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +16 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
|
@@ -5,10 +5,10 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
import { Backoff, HubError, normalProject } from './fabric-hub.mjs';
|
|
6
6
|
import { acceptedRevisions, feedDataProblems, negotiateRevision } from './fabric-service.mjs';
|
|
7
7
|
import { BoardError, CAPABILITY, LEASE_SECONDS, REQUEST_STATES, SUMMARY_MAX, Stopped, isInt, isObj, outcomeProblems, receiptProblems } from './fabric-board-shapes.mjs';
|
|
8
|
-
import { BoardRequest, BoardSession, Checkpoint } from './fabric-board-link.mjs';
|
|
8
|
+
import { BoardRequest, BoardSession, Checkpoint, PERSONAL_BOARD, covers, mandateOptions, readMandate } from './fabric-board-link.mjs';
|
|
9
9
|
import { HOLD_RETRY_MAX_SECONDS, LEASE_MARGIN_SECONDS, SESSION_CODES, SlotKeeper } from './fabric-board-slots.mjs';
|
|
10
10
|
|
|
11
|
-
export { BoardRequest, BoardSession, Checkpoint }; // re-exported: callers import them from here since 0.40.0
|
|
11
|
+
export { BoardRequest, BoardSession, Checkpoint, PERSONAL_BOARD }; // re-exported: callers import them from here since 0.40.0
|
|
12
12
|
export { HOLD_RETRY_MAX_SECONDS, LEASE_MARGIN_SECONDS, SESSION_CODES }; // since 0.56.0 defined in fabric-board-slots.mjs
|
|
13
13
|
|
|
14
14
|
export const RENEW_SECONDS = 20;
|
|
@@ -34,10 +34,23 @@ const NOT_OURS = new Set(['queued', 'claimed', 'answered']);
|
|
|
34
34
|
export class WorkContext {
|
|
35
35
|
#responder;
|
|
36
36
|
#entry;
|
|
37
|
-
|
|
37
|
+
#caller;
|
|
38
|
+
constructor(responder, entry, mandate = null, caller = null) {
|
|
38
39
|
this.#responder = responder;
|
|
39
40
|
this.#entry = entry;
|
|
41
|
+
this.#caller = caller;
|
|
40
42
|
this.stopped = false;
|
|
43
|
+
this.mandate = mandate; // the operator's mandate the request carried, when the responder reads one (FAA-135)
|
|
44
|
+
}
|
|
45
|
+
/** Whether the request's mandate covers `call` (`mandateCovers`' answer); null when none travelled. */
|
|
46
|
+
mandateCovers(call) {
|
|
47
|
+
return covers(this.mandate, call, this.#responder.agentId, this.#responder.wall, this.#caller);
|
|
48
|
+
}
|
|
49
|
+
toJSON() {
|
|
50
|
+
return { stopped: this.stopped }; // never the mandate: it holds the operator's words
|
|
51
|
+
}
|
|
52
|
+
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
53
|
+
return this.toJSON(); // console.log(ctx) prints no mandate either
|
|
41
54
|
}
|
|
42
55
|
async progress(note) {
|
|
43
56
|
if (this.stopped) throw new Stopped();
|
|
@@ -69,13 +82,15 @@ function noticeSubject(value, project, episode) {
|
|
|
69
82
|
*/
|
|
70
83
|
export class Responder extends SlotKeeper {
|
|
71
84
|
constructor({ hub, projectId, capabilities, credentialFile, checkpointFile, handler, admit, observe, emit, service = 'service',
|
|
72
|
-
now = () => performance.now() / 1000, rand = Math.random, sessionFactory } = {}) {
|
|
85
|
+
now = () => performance.now() / 1000, rand = Math.random, sessionFactory, agentId = null, mandateFrom = null, wall = Date.now } = {}) {
|
|
73
86
|
super();
|
|
74
87
|
if (typeof admit !== 'function' || typeof handler !== 'function') throw new BoardError('invalid_arguments', 'A responder needs the service\'s own input gate (admit) and a handler.');
|
|
75
88
|
const caps = [...(capabilities ?? [])];
|
|
76
89
|
if (!caps.length || caps.some((c) => typeof c !== 'string' || !CAPABILITY.test(c)) || new Set(caps).size !== caps.length) throw new BoardError('invalid_arguments', 'Name each capability once, [a-z][a-z0-9._-]{1,127}.');
|
|
77
90
|
Object.assign(this, { hub, capabilities: caps, credentialFile, handler, admit, observe, emitTo: emit, now, rand });
|
|
78
91
|
this.project = normalProject(projectId);
|
|
92
|
+
// FAA-135: read the operator's mandate from a trusted request of his own Project (opt-in; null reads none)
|
|
93
|
+
Object.assign(this, { mandateFrom: mandateOptions(agentId, mandateFrom), agentId: agentId ?? null, wall });
|
|
79
94
|
this.service = String(service).replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 40) || 'service';
|
|
80
95
|
this.checkpoint = new Checkpoint(checkpointFile);
|
|
81
96
|
this.sessionFactory = sessionFactory ?? ((grant) => new BoardSession(grant));
|
|
@@ -679,7 +694,11 @@ export class Responder extends SlotKeeper {
|
|
|
679
694
|
}
|
|
680
695
|
|
|
681
696
|
async runWork(entry, request) {
|
|
682
|
-
const
|
|
697
|
+
const { mandate, unread } = readMandate(request, this.mandateFrom, this.agentId);
|
|
698
|
+
if (unread) { // by its code alone: the block holds the operator's words
|
|
699
|
+
this.emit('board.request.mandate_unread', 'warning', `Request ${entry.request} carries a mandate the kit does not read (${unread}); it is worked without one.`, { request: entry.request, reason: unread });
|
|
700
|
+
}
|
|
701
|
+
const ctx = new WorkContext(this, entry, mandate, isObj(request.sender) ? request.sender.project ?? null : null);
|
|
683
702
|
const work = { entry, request, ctx, done: false, outcome: null, failure: null, renewedAt: this.now() };
|
|
684
703
|
this.current = work;
|
|
685
704
|
work.promise = Promise.resolve().then(() => this.handler(request, ctx)).then((outcome) => { work.outcome = outcome; }, (e) => { work.failure = e; }).finally(() => { work.done = true; });
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs
CHANGED
|
@@ -432,7 +432,9 @@ export class JwksCache {
|
|
|
432
432
|
if (typeof source !== 'string' || !source) throw new Error('the key set needs a source');
|
|
433
433
|
if (/^https?:\/\//.test(source)) {
|
|
434
434
|
const url = new URL(source);
|
|
435
|
-
|
|
435
|
+
const text = source.replace(/^[\x00-\x20]+|[\x00-\x20]+$/g, '').replace(/[\t\n\r]/g, '');
|
|
436
|
+
// the authority as written too, as Python's netloc reads it (`https://h\\u:p@x/j`, `https://@x/j`: seam reading 13)
|
|
437
|
+
if (url.username || url.password || /^[a-z][a-z0-9+.-]*:[/\\]*[^/?#]*@/i.test(text)) throw new Error('a key set URL carries no credentials');
|
|
436
438
|
if (url.protocol === 'http:' && !loopback(url.hostname)) throw new Error('a key set is read over https, or over http on loopback only');
|
|
437
439
|
} else if (source.includes('://')) {
|
|
438
440
|
throw new Error('a key set is an https URL, a loopback http URL or a file path');
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Part of the Node service kit (FAA-65, layer L3); the twin of fabric_descriptor.py. Node 20+, no dependency.
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { credentialName, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
|
|
5
|
+
import { credentialName, repositoryCredential, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
|
|
6
6
|
import { ID, INSTANCE, LOCAL_PATH, PROTOCOL, ServiceError, TASK, UNIT, expand } from './fabric-base.mjs';
|
|
7
7
|
import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
|
|
8
8
|
import { protocolsFindings } from './fabric-protocols.mjs';
|
|
@@ -182,7 +182,7 @@ export function descriptorMemberProblems(d) {
|
|
|
182
182
|
for (const k of Object.keys(runner)) if (!['kind', 'credentialKind'].includes(k)) problems.push(`runner has no member ${k}`);
|
|
183
183
|
if (!CREDENTIAL_KINDS.includes(runner.credentialKind)) problems.push('runner.credentialKind is subscription, key, platform-metered or none');
|
|
184
184
|
if ('kind' in runner && !(typeof runner.kind === 'string' && RUNNER_KIND_NAME.test(runner.kind))) problems.push("runner.kind is a runner kind's name");
|
|
185
|
-
if (runner.credentialKind === 'subscription' && !Object.hasOwn(SUBSCRIPTION_RUNNERS, runner.kind)) problems.push("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex");
|
|
185
|
+
if (runner.credentialKind === 'subscription' && !(typeof runner.kind === 'string' && Object.hasOwn(SUBSCRIPTION_RUNNERS, runner.kind))) problems.push("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex");
|
|
186
186
|
for (const [k, v] of Object.entries(runner)) places.push([`/runner/${pointer(k)}`, v]);
|
|
187
187
|
}
|
|
188
188
|
if ('source' in d) {
|
|
@@ -194,7 +194,8 @@ export function descriptorMemberProblems(d) {
|
|
|
194
194
|
if (!(typeof repository === 'string' && SOURCE_URI.test(repository.replace(IP_LITERAL, '$1h')))) problems.push('source.repository is a URI (RFC 3986), such as https://github.com/owner/name');
|
|
195
195
|
else if (URI_PASSWORD.test(repository)) problems.push('source.repository carries no password: it names the repository, never a credential'); // before the host
|
|
196
196
|
else if (!authorityOk(repository)) problems.push('source.repository names a host, and a port of digits up to 65535 when it has one');
|
|
197
|
-
|
|
197
|
+
else if (repository.includes('@') && !HIERARCHICAL.test(repository)) problems.push('source.repository with a user is a URL with an authority (https://user@host/path), never an scp form'); // readings 7
|
|
198
|
+
else if (repositoryCredential(repository)) problems.push('FAC-SEM-086: /source/repository holds a value shaped like a credential; it names a repository'); // its name is no key (seam 6)
|
|
198
199
|
}
|
|
199
200
|
}
|
|
200
201
|
if ('peer' in d) {
|
|
@@ -290,10 +291,39 @@ export function portOf(origin) {
|
|
|
290
291
|
return Number(m[1]);
|
|
291
292
|
}
|
|
292
293
|
|
|
294
|
+
export const NO_SOURCE_REPOSITORY = 'names no source.repository, the repository its code is built from '
|
|
295
|
+
+ '(https://github.com/owner/name): it links the agent to its project (FAA-198)';
|
|
296
|
+
const PASS_SOURCE = 'Name it in this call: source: { repository } in the descriptor writeDescriptor writes, or in '
|
|
297
|
+
+ 'registerRemote\'s options; one written after the call returns is too late for a new install.';
|
|
298
|
+
|
|
299
|
+
const HIERARCHICAL = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
|
|
300
|
+
|
|
301
|
+
// FAA-198: why a new install of this (valid) descriptor is refused, or null: it names no source.repository and no
|
|
302
|
+
// descriptor of its id.instance is installed yet. An installer checks it before it writes anything else.
|
|
303
|
+
export const namesRepository = (d) => d?.source !== null && typeof d?.source === 'object' && !Array.isArray(d.source)
|
|
304
|
+
&& typeof d.source.repository === 'string' && d.source.repository !== '';
|
|
305
|
+
|
|
306
|
+
// An installed descriptor is a regular file, whatever it holds; a folder, a broken link or a path that cannot be
|
|
307
|
+
// examined is none.
|
|
308
|
+
export function sourceRefusal(d, dir = servicesDir()) {
|
|
309
|
+
if (namesRepository(d)) return null;
|
|
310
|
+
const instance = d?.instance ?? 'default';
|
|
311
|
+
// An id or instance that is no string names no installed file, in both kits (seam reading 3).
|
|
312
|
+
try { if (typeof d?.id === 'string' && typeof instance === 'string' && fs.statSync(descriptorPath(d.id, instance, dir)).isFile()) return null; } catch { /* not installed */ }
|
|
313
|
+
return `Descriptor ${d?.id}.${instance} ${NO_SOURCE_REPOSITORY}. ${PASS_SOURCE}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// FAA-198: a new descriptor names its source.repository; rewriting one already installed without it only warns
|
|
317
|
+
// (process.emitWarning, code FABRIC_NO_SOURCE_REPOSITORY), so an installed agent's update keeps working.
|
|
293
318
|
export function writeDescriptor(d, dir = servicesDir()) {
|
|
294
319
|
const problems = validateDescriptor(d);
|
|
295
320
|
if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
|
|
296
321
|
const me = `${d.id}.${d.instance}`;
|
|
322
|
+
const refusal = sourceRefusal(d, dir);
|
|
323
|
+
if (refusal) throw new ServiceError(refusal);
|
|
324
|
+
if (!namesRepository(d)) {
|
|
325
|
+
process.emitWarning(`Descriptor ${me} ${NO_SOURCE_REPOSITORY}; a new install is refused without it.`, { code: 'FABRIC_NO_SOURCE_REPOSITORY' });
|
|
326
|
+
}
|
|
297
327
|
// DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
|
|
298
328
|
const port = placementOf(d) === 'remote' || d.origin === undefined ? null : portOf(d.origin);
|
|
299
329
|
for (const [file, other] of readDescriptors(dir)) {
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs
CHANGED
|
@@ -52,8 +52,17 @@ export class Response {
|
|
|
52
52
|
|
|
53
53
|
// The URL as it may appear in an error or a log line: scheme, host, port and path; no userinfo, query or fragment.
|
|
54
54
|
export function redactUrl(url) {
|
|
55
|
+
let text = String(url).replace(/^[\x00-\x20]+|[\x00-\x20]+$/g, '').replace(/[\t\n\r]/g, '');
|
|
56
|
+
// The user information as written goes first, as the Python twin drops it: `https://h\\u:pw@x/r` would print `pw` in
|
|
57
|
+
// its path, a backslash being a slash to WHATWG (0.62.0 seam reading 11).
|
|
58
|
+
const written = /^([A-Za-z][A-Za-z0-9+.-]*:[/\\]*)([^/?#]*)/.exec(text); // after any run of slashes (seam reading 13)
|
|
59
|
+
if (written && written[2].includes('@')) text = written[1] + written[2].slice(written[2].lastIndexOf('@') + 1) + text.slice(written[0].length);
|
|
55
60
|
let parts;
|
|
56
|
-
try { parts = new URL(
|
|
61
|
+
try { parts = new URL(text); } catch {
|
|
62
|
+
// a scheme other than a special one keeps its name, as the Python twin prints it whatever follows (unit reading 14)
|
|
63
|
+
const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(text)?.[1].toLowerCase();
|
|
64
|
+
return scheme && !['http', 'https', 'ws', 'wss', 'ftp'].includes(scheme) ? `${scheme}:<redacted>` : '<unparseable url>';
|
|
65
|
+
}
|
|
57
66
|
// Only http and https are sent; any other scheme keeps no more than its name (0.56.0 product r10).
|
|
58
67
|
if (parts.protocol !== 'http:' && parts.protocol !== 'https:') return `${parts.protocol}<redacted>`;
|
|
59
68
|
return `${parts.protocol}//${parts.host}${parts.pathname}`;
|
|
@@ -74,7 +83,10 @@ export function checkUrl(url, { insecure = false } = {}) {
|
|
|
74
83
|
if (!['http:', 'https:'].includes(parts.protocol) || !parts.hostname) {
|
|
75
84
|
throw new HttpError('only http and https URLs with a host are sent', { url: redactUrl(url), kind: 'refused' });
|
|
76
85
|
}
|
|
77
|
-
|
|
86
|
+
// the user information as written, read on the text `new URL` reads (C0 and space off its ends, tab, CR and LF out),
|
|
87
|
+
// so `ht\ttps://h\\u:pw@x/r` is refused as the Python twin refuses it (0.62.0 unit reading 12)
|
|
88
|
+
const text = String(url).replace(/^[\x00-\x20]+|[\x00-\x20]+$/g, '').replace(/[\t\n\r]/g, '');
|
|
89
|
+
if (parts.username || parts.password || /^[a-z][a-z0-9+.-]*:[/\\]*[^/?#]*@/i.test(text)) {
|
|
78
90
|
throw new HttpError('credentials in a URL are refused: send them in a header', { url: redactUrl(url), kind: 'refused' });
|
|
79
91
|
}
|
|
80
92
|
if (parts.protocol === 'http:' && !insecure && !isLoopback(parts.hostname)) {
|
|
@@ -282,6 +282,16 @@ export function mandateSpend(value) {
|
|
|
282
282
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
/** A job's approval step {effect, spendUsd?, project?} as a job file keeps it: read as the gate reads it, so a job's
|
|
286
|
+
* start and its answer agree (0.61.0 seam reading 32, readings 33 and 34): a spend through mandateSpend (dropped when none), a project
|
|
287
|
+
* as a primitive string, else UNREADABLE_PROJECT (dropped when null or undefined). */
|
|
288
|
+
export function keptStep(step) {
|
|
289
|
+
const { effect, spendUsd, project } = step; // each member read once (seam reading 34)
|
|
290
|
+
const spend = mandateSpend(spendUsd);
|
|
291
|
+
return { effect, ...(spend !== undefined ? { spendUsd: spend } : {}),
|
|
292
|
+
...(project !== undefined && project !== null ? { project: typeof project === 'string' ? project : UNREADABLE_PROJECT } : {}) };
|
|
293
|
+
}
|
|
294
|
+
|
|
285
295
|
export function actionGate({ action, tier, caller, meta, target, elicited = false, approval, actionDigest, humanStepUrl, now, self, effect, callerAgent, spendUsd, project, mandateCapability } = {}) {
|
|
286
296
|
const inputs = { action, tier, caller, elicited: elicited === true, now };
|
|
287
297
|
if (isObject(meta) && Object.hasOwn(meta, 'decision')) inputs.decision = meta.decision;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs
CHANGED
|
@@ -41,6 +41,7 @@ export {
|
|
|
41
41
|
jobStep,
|
|
42
42
|
actionGate,
|
|
43
43
|
mandateSpend,
|
|
44
|
+
keptStep,
|
|
44
45
|
UNREADABLE_PROJECT,
|
|
45
46
|
actionRefusalToolResult,
|
|
46
47
|
usageFindings,
|
|
@@ -49,7 +50,7 @@ export {
|
|
|
49
50
|
pauseMessage,
|
|
50
51
|
repeatedKeyDecision,
|
|
51
52
|
} from './fabric-interop-rules.mjs';
|
|
52
|
-
import { PROTOCOL, EXTENSION_KEY, MCP_REVISION, TERMINAL, CONTRACT_VERSION, JOB_HANDLE_SCHEMA, InteropError, UnknownJob, parseTraceparent, childTraceparent, traceIds, QUOTA_EXHAUSTED, QUEUE_FULL, OUTCOME_UNKNOWN, LIMIT_REACHED, INTEROP_NULL_USAGE, PROTOCOLS_READ, utcTime, quotaDataOk, readJobPause, jobCallerAction, intentIdempotencyKey, readsNullUsage, requestMeta, usageFindings, usageToward, jobLimitRules, pauseMessage, repeatedKeyDecision, OUTCOMES, ENVELOPE_REQUIRED, JOB_ID, KEY, SECRET_WORDS } from './fabric-interop-rules.mjs'; // the rule layer (FAA-92)
|
|
53
|
+
import { PROTOCOL, EXTENSION_KEY, MCP_REVISION, TERMINAL, CONTRACT_VERSION, JOB_HANDLE_SCHEMA, InteropError, UnknownJob, parseTraceparent, childTraceparent, traceIds, QUOTA_EXHAUSTED, QUEUE_FULL, OUTCOME_UNKNOWN, LIMIT_REACHED, INTEROP_NULL_USAGE, PROTOCOLS_READ, utcTime, quotaDataOk, readJobPause, jobCallerAction, intentIdempotencyKey, readsNullUsage, requestMeta, usageFindings, usageToward, jobLimitRules, pauseMessage, repeatedKeyDecision, keptStep, OUTCOMES, ENVELOPE_REQUIRED, JOB_ID, KEY, SECRET_WORDS } from './fabric-interop-rules.mjs'; // the rule layer (FAA-92)
|
|
53
54
|
|
|
54
55
|
// --- C3.1 capabilities as tools ----------------------------------------------------------
|
|
55
56
|
export function expectedAnnotations(effect, idempotency) {
|
|
@@ -266,23 +267,28 @@ export class JobStore {
|
|
|
266
267
|
|
|
267
268
|
// `steps` (DEC-0125) maps a request key to the approval step it asks for, {effect, spendUsd?, project?}, kept in the
|
|
268
269
|
// job's private part (the format fabric_interop.py writes) so a service reads an answer's _meta.mandate for that step.
|
|
270
|
+
// A step is kept as the gate reads it (keptStep), so a job's start and its answer agree.
|
|
269
271
|
requestInput(jobId, inputRequests, statusMessage, { steps } = {}) {
|
|
272
|
+
const entries = Object.entries(inputRequests ?? {});
|
|
273
|
+
if (!entries.length || Array.isArray(inputRequests) || typeof inputRequests !== 'object') throw new InteropError('input_required needs a JSON object of at least one input request.'); // a JSON object of requests, first (unit 38, 40)
|
|
274
|
+
const kept = Object.create(null); // the value checked is the value kept (seam 34); a key __proto__ stays a key (35)
|
|
270
275
|
for (const [key, step] of Object.entries(steps ?? {})) {
|
|
271
|
-
|
|
276
|
+
kept[key] = Object.hasOwn(inputRequests, key) && step !== null && step !== undefined ? keptStep(step) : {};
|
|
277
|
+
if (!EFFECT_CLASSES.includes(kept[key].effect)) throw new InteropError(`Step ${key}: an input request's key with its effect class (DEC-0125).`);
|
|
272
278
|
}
|
|
273
|
-
const entries
|
|
274
|
-
if (!entries.length) throw new InteropError('input_required needs at least one input request.');
|
|
275
|
-
for (const [key, request] of entries) if (!KEY.test(key) || request?.method !== 'elicitation/create') throw new InteropError(`Input request ${key} must be a keyed elicitation/create request.`);
|
|
279
|
+
for (const [key, request] of entries) if (!KEY.test(key) || request === null || typeof request !== 'object' || Array.isArray(request) || request.method !== 'elicitation/create') throw new InteropError(`Input request ${key} must be a keyed elicitation/create request.`);
|
|
276
280
|
const [finding] = inputRequestLinkFindings({ inputRequests }); // FAC-SEM-143 (D12)
|
|
277
281
|
if (finding) throw new InteropError(`${finding.instancePath}: ${finding.message}`);
|
|
278
|
-
return this.transition(jobId, 'input_required', { inputRequests, statusMessage }, { steps:
|
|
282
|
+
return this.transition(jobId, 'input_required', { inputRequests, statusMessage }, { steps: kept });
|
|
279
283
|
}
|
|
280
284
|
|
|
281
285
|
answer(jobId, inputResponses) {
|
|
282
286
|
const job = this.get(jobId);
|
|
283
287
|
if (job.status !== 'input_required') return {};
|
|
284
|
-
const
|
|
285
|
-
|
|
288
|
+
const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); // as Python's dict (seam r37)
|
|
289
|
+
const asked = plain(job.inputRequests) ? job.inputRequests : {};
|
|
290
|
+
const matched = Object.fromEntries(Object.entries(plain(inputResponses) ? inputResponses : {}).filter(([key, value]) =>
|
|
291
|
+
Object.hasOwn(asked, key) && ['accept', 'decline', 'cancel'].includes(value?.action))); // an asked key, never an inherited name (seam 36)
|
|
286
292
|
// The answered question's kept steps go in the same write: none outlives its answer.
|
|
287
293
|
if (Object.keys(matched).length) this.transition(jobId, 'working', {}, { steps: {} });
|
|
288
294
|
return matched;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs
CHANGED
|
@@ -43,9 +43,19 @@ export class InstanceLock {
|
|
|
43
43
|
if (error.code === 'EAGAIN' || error.code === 'EWOULDBLOCK') throw new AlreadyRunning(readPid(this.path), this.path);
|
|
44
44
|
throw error;
|
|
45
45
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
try {
|
|
47
|
+
fs.ftruncateSync(this.fd, 0);
|
|
48
|
+
fs.writeSync(this.fd, String(process.pid));
|
|
49
|
+
fs.fsyncSync(this.fd);
|
|
50
|
+
} catch (error) { // a full disk or an I/O error: let the lock go, or nobody can (the caller drops this object)
|
|
51
|
+
const fd = this.fd;
|
|
52
|
+
this.fd = null;
|
|
53
|
+
try {
|
|
54
|
+
fs.closeSync(fd);
|
|
55
|
+
} catch { // a deferred write error (NFS) at the close: the lock still goes, the first error stands
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
49
59
|
return this;
|
|
50
60
|
}
|
|
51
61
|
// Linux: an abstract unix socket; Windows: a named pipe. Each is exclusive, has no file, and dies
|
|
@@ -66,7 +76,17 @@ export class InstanceLock {
|
|
|
66
76
|
this.server.listen(name, resolve);
|
|
67
77
|
});
|
|
68
78
|
this.server.unref();
|
|
69
|
-
|
|
79
|
+
try {
|
|
80
|
+
atomicWrite(this.path, String(process.pid));
|
|
81
|
+
} catch (error) { // as on macOS: the name is let go with the failed write, the first error standing
|
|
82
|
+
const server = this.server;
|
|
83
|
+
this.server = null;
|
|
84
|
+
try {
|
|
85
|
+
server.close();
|
|
86
|
+
} catch { // nothing to add to the first error
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
70
90
|
return this;
|
|
71
91
|
}
|
|
72
92
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs
CHANGED
|
@@ -45,6 +45,9 @@ export const OPERATOR_SOURCE_PROTOCOL = 'operator-source/1';
|
|
|
45
45
|
const SCOPE_MEMBERS = new Set(['capabilities', 'actions', 'effects', 'perCallMaxUsd', 'projects']);
|
|
46
46
|
const STEP_MEMBERS = new Set(['agent', 'at', 'scope']);
|
|
47
47
|
const AGENT_ID = /^[a-z][a-z0-9-]{1,62}(\.[a-z][a-z0-9-]{0,31})?$/; // common.schema.json agentId
|
|
48
|
+
|
|
49
|
+
/** A registry agent id (common.schema.json agentId): a provider or service id, optionally `.instance`. */
|
|
50
|
+
export const isAgentId = (value) => typeof value === 'string' && AGENT_ID.test(value);
|
|
48
51
|
const CAPABILITY = /^[a-z][a-z0-9._-]{1,127}$/; // common.schema.json capabilityName
|
|
49
52
|
const REQUEST_KEY = /^[A-Za-z0-9_.-]{1,64}$/; // interop-common.schema.json requestKey
|
|
50
53
|
const PROJECT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs
CHANGED
|
@@ -84,3 +84,60 @@ export function secretUrl(value) {
|
|
|
84
84
|
}
|
|
85
85
|
return false;
|
|
86
86
|
}
|
|
87
|
+
|
|
88
|
+
const REPOSITORY_URI = /^([A-Za-z][A-Za-z0-9+.-]*:(?:\/\/[^/?#]*)?)([^?#]*)([\s\S]*)$/;
|
|
89
|
+
const REPOSITORY_NAME = /^(?:[a-z0-9._-]+|[A-Za-z._-]+)$/; // lower case, or letters with no digit (readings 8)
|
|
90
|
+
const REPOSITORY_AUTHORITY = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
|
|
91
|
+
const KEY_AT_A_WORD = new RegExp(`(?:^|(?<=[._-]))(?:${SECRET_VALUE.source})`, 'i');
|
|
92
|
+
const JWT_AT_A_WORD = new RegExp(`(?:^|(?<=[._-]))${JWT_SHAPE.source}`);
|
|
93
|
+
const SPECIAL_QUERY = /^(?:https?|wss?|ftp|file):/i; // whose query also encodes `'`, as the URL standard does
|
|
94
|
+
|
|
95
|
+
/** Whether a path segment reads as a repository's name: lower case, or letters with no digit, with no provider key's
|
|
96
|
+
* or JWT's shape at its start or after a `.`, `_` or `-` (0.62.0 readings 9 and 10), so a letters-only key is a key. */
|
|
97
|
+
export const repositoryName = (piece) => REPOSITORY_NAME.test(piece) && !KEY_AT_A_WORD.test(piece) && !JWT_AT_A_WORD.test(piece);
|
|
98
|
+
|
|
99
|
+
/** Every `%XX` escape of an ASCII character decoded, the rest as written (fabric_secrets.ascii_decoded, readings 11). */
|
|
100
|
+
export const asciiDecoded = (text) => text.replace(/%([0-7][0-9A-Fa-f])/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
101
|
+
|
|
102
|
+
/** Whether a repository URI's user information holds a password: every slash and backslash after the colon skipped,
|
|
103
|
+
* then up to the next `/`, `?` or `#` (fabric_secrets.repository_password, 0.62.0 readings 10 to 14). */
|
|
104
|
+
export function repositoryPassword(value) {
|
|
105
|
+
const text = value.replace(/^[\x00-\x20]+|[\x00-\x20]+$/g, '').replace(/[\t\n\r]/g, '');
|
|
106
|
+
const colon = text.indexOf(':');
|
|
107
|
+
const rest = colon < 0 ? '' : text.slice(colon + 1); // as Python's partition leaves it (unit reading 12)
|
|
108
|
+
const authorities = colon < 0 ? [] : [/^[^/?#]*/.exec(rest.replace(/^[/\\]+/, ''))[0]];
|
|
109
|
+
const users = authorities.map((authority) => authority.slice(0, Math.max(authority.lastIndexOf('@'), 0)));
|
|
110
|
+
return [...users, ...users.map(asciiDecoded)].some((user) => user.includes(':') && user.slice(user.indexOf(':') + 1) !== '');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A repository's parameters, read as fabric_secrets.secret_url reads them, by hand: `new URL`
|
|
114
|
+
// refuses a host it cannot parse and a special scheme written without `//` (`https:host/r?access_token=...`), where
|
|
115
|
+
// the Python twin still reads them (0.62.0 readings 8 and 9, FAA-200 (17)).
|
|
116
|
+
function repositoryParts(head, tail) {
|
|
117
|
+
const text = tail.replace(/[\x00-\x20]+$/, '').replace(/[\t\n\r]/g, '');
|
|
118
|
+
const hash = text.indexOf('#');
|
|
119
|
+
const before = hash < 0 ? text : text.slice(0, hash);
|
|
120
|
+
const query = before.startsWith('?') ? before.slice(1) : '';
|
|
121
|
+
const fragment = hash < 0 ? '' : text.slice(hash + 1);
|
|
122
|
+
try { // percent-encode them as `new URL` does, on a host it always reads
|
|
123
|
+
const url = new URL(`${SPECIAL_QUERY.test(head) ? 'https://h.invalid/' : 'x-repository://h/'}?${query}#${fragment}`);
|
|
124
|
+
return [url.search.slice(1), url.hash.slice(1)];
|
|
125
|
+
} catch { return [query, fragment]; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// FAA-198 (0.62.0 readings 6 and 7): whether a repository URI carries a credential: in its scheme and authority (a
|
|
129
|
+
// user), in its query or fragment as secretUrl reads them, or as a path segment shaped like a key. A path segment in
|
|
130
|
+
// lower case, or of letters with no digit (task-pipeline-dashboard, Task-Pipeline-Dashboard), is the repository's
|
|
131
|
+
// name, never a key, though it reads as an `sk-` shape, unless a key's shape starts it or follows a `.`, `_` or `-` in
|
|
132
|
+
// it (repositoryName); a segment is read as written and asciiDecoded. A
|
|
133
|
+
// user outside a URL's authority (an scp form, `https:user@host/path`) counts as one: a user is an authority's.
|
|
134
|
+
export function repositoryCredential(value) {
|
|
135
|
+
if (value.includes('@') && !REPOSITORY_AUTHORITY.test(value)) return true;
|
|
136
|
+
const match = REPOSITORY_URI.exec(value);
|
|
137
|
+
if (!match) return secretArgument(value);
|
|
138
|
+
const [, head, path, tail] = match;
|
|
139
|
+
const odd = [path, asciiDecoded(path)].flatMap((written) => written.split(/[/\\:]/)).filter((piece) => piece && !repositoryName(piece));
|
|
140
|
+
if (repositoryPassword(value) || secretArgument(head + tail) || secretArgument(asciiDecoded(head)) || odd.some((piece) => secretArgument(piece))) return true; // the authority decoded too (unit reading 12)
|
|
141
|
+
return repositoryParts(head, tail).some((part) => [...rawValues(part)].some(([name, values]) =>
|
|
142
|
+
values.some((raw) => (raw !== '' && credentialName(name)) || secretValue(raw))));
|
|
143
|
+
}
|
|
@@ -36,6 +36,9 @@ from fabric_paths import (
|
|
|
36
36
|
services_dir,
|
|
37
37
|
)
|
|
38
38
|
from fabric_descriptor import (
|
|
39
|
+
descriptor_path,
|
|
40
|
+
names_repository,
|
|
41
|
+
source_refusal,
|
|
39
42
|
validate_descriptor,
|
|
40
43
|
write_descriptor,
|
|
41
44
|
)
|
|
@@ -393,6 +396,15 @@ def register_remote(*, service_id: str, name: str, origin: str, token: str, inst
|
|
|
393
396
|
descriptor["runner"] = dict(runner)
|
|
394
397
|
if peer is not None:
|
|
395
398
|
descriptor["peer"] = dict(peer)
|
|
399
|
+
if source is None: # a rotation that names no source keeps the installed one (0.62.0 seam reading 1)
|
|
400
|
+
try:
|
|
401
|
+
installed = json.loads(descriptor_path(service_id, instance, root).read_text(encoding="utf-8")).get("source")
|
|
402
|
+
except (OSError, ValueError, AttributeError):
|
|
403
|
+
installed = None
|
|
404
|
+
# Only one the kit accepts: a hand-edited source never blocks a rotation; it is dropped with the warning.
|
|
405
|
+
base = set(validate_descriptor(descriptor))
|
|
406
|
+
source = installed if names_repository({"source": installed}) and set(
|
|
407
|
+
validate_descriptor(dict(descriptor, source=installed))) <= base else None
|
|
396
408
|
if source is not None:
|
|
397
409
|
descriptor["source"] = dict(source) if isinstance(source, dict) else source
|
|
398
410
|
if doctor:
|
|
@@ -400,9 +412,12 @@ def register_remote(*, service_id: str, name: str, origin: str, token: str, inst
|
|
|
400
412
|
problems = validate_descriptor(descriptor)
|
|
401
413
|
if problems:
|
|
402
414
|
raise ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
|
|
415
|
+
refusal = source_refusal(descriptor, root) # before the token is written: a refused install leaves nothing (FAA-198)
|
|
416
|
+
if refusal:
|
|
417
|
+
raise ServiceError(refusal)
|
|
403
418
|
ensure_private_dir(token_file.parent)
|
|
404
419
|
atomic_write(token_file, token.strip().encode(), 0o600)
|
|
405
|
-
return write_descriptor(descriptor, root)
|
|
420
|
+
return write_descriptor(descriptor, root, _stacklevel=3) # the warning names the installer's call
|
|
406
421
|
|
|
407
422
|
|
|
408
423
|
def cookie_value(cookie_header: Optional[str], name: str = SESSION_COOKIE) -> Optional[str]:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py
CHANGED
|
@@ -4,18 +4,26 @@ names. Standard library only, Python 3.9+."""
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import json
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Mapping
|
|
7
10
|
import threading
|
|
8
11
|
from pathlib import Path
|
|
9
12
|
from typing import Any
|
|
10
13
|
from typing import Callable
|
|
11
14
|
from typing import Dict
|
|
15
|
+
from typing import List
|
|
12
16
|
from typing import Optional
|
|
17
|
+
from typing import Tuple
|
|
13
18
|
|
|
14
19
|
import sys as _sys # noqa: E402
|
|
15
20
|
from pathlib import Path as _Path # noqa: E402
|
|
16
21
|
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
17
22
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
18
23
|
import fabric_hub as _hub
|
|
24
|
+
import fabric_interop_rules as _rules
|
|
25
|
+
import fabric_mandate as _mandate
|
|
26
|
+
from fabric_time import format_rfc3339 as _format_rfc3339 # noqa: E402 - the core's (FAA-65)
|
|
19
27
|
import fabric_service as _fs
|
|
20
28
|
from fabric_board_shapes import ( # noqa: E402 - the kit (FAA-65)
|
|
21
29
|
BoardError,
|
|
@@ -210,3 +218,141 @@ class BoardRequest:
|
|
|
210
218
|
|
|
211
219
|
def __repr__(self) -> str: # never the body
|
|
212
220
|
return "BoardRequest(%r, %r)" % (self.request_id, self.capability)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# -- the operator's mandate in a board request (FAA-135; contract project-comms.md, DEC-0125 MD-3) ---------------------
|
|
224
|
+
|
|
225
|
+
#: `mandate_from` naming every trusted sender of the board the service is installed on: the operator's personal board.
|
|
226
|
+
PERSONAL_BOARD = "personal-board"
|
|
227
|
+
_OPENER = re.compile(r"```json[ \t]*")
|
|
228
|
+
_CLOSER = re.compile(r"```[ \t]*")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _json_blocks(body: str) -> List[str]:
|
|
232
|
+
"""The bodies of the ```json blocks, each fence on its own line: one pass over the lines, so linear in the body
|
|
233
|
+
(an anchored regex rescanned to the end from every unclosed opener). Lines end at "\\n" alone, one "\\r" before it
|
|
234
|
+
dropped, as in the Node twin; an unclosed opener ends the scan."""
|
|
235
|
+
lines = [line[:-1] if line.endswith("\r") else line for line in body.split("\n")]
|
|
236
|
+
blocks: List[str] = []
|
|
237
|
+
i = 0
|
|
238
|
+
while i < len(lines):
|
|
239
|
+
if _OPENER.fullmatch(lines[i]):
|
|
240
|
+
j = i + 1
|
|
241
|
+
while j < len(lines) and not _CLOSER.fullmatch(lines[j]):
|
|
242
|
+
j += 1
|
|
243
|
+
if j == len(lines):
|
|
244
|
+
break
|
|
245
|
+
blocks.append("\n".join(lines[i + 1:j]))
|
|
246
|
+
i = j
|
|
247
|
+
i += 1
|
|
248
|
+
return blocks
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _no_constant(name: str) -> Any:
|
|
252
|
+
raise ValueError(name) # NaN and Infinity are not JSON
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _bounded_int(literal: str) -> int:
|
|
256
|
+
"""An integer of at most 4300 digits on every Python: 3.9 without int_max_str_digits converts a longer one in
|
|
257
|
+
quadratic time, and later ones refuse it, so all of them now refuse it alike."""
|
|
258
|
+
if len(literal) - literal.startswith("-") > 4300:
|
|
259
|
+
raise ValueError("an integer longer than 4300 digits")
|
|
260
|
+
return int(literal)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def read_mandate(request: "BoardRequest", mandate_from: Any, agent_id: Optional[str]) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
264
|
+
"""The mandate a request carries, read only when the service opted in (`agent_id` and `mandate_from` set), the board
|
|
265
|
+
verified the sender (`provenance: trusted`) and its Project is one `mandate_from` names (or `PERSONAL_BOARD`): a T1
|
|
266
|
+
caller by project-comms. Answers `(mandate, None)`, `(None, None)` when none is read, or `(None, code)` for a body
|
|
267
|
+
that carries one the kit will not read: `mandate-blocks` (more than one) or `mandate-unreadable` (a block naming
|
|
268
|
+
`mandate/1` that is not JSON). A `source` in the block is dropped: a board block carries none (OA-7). Whether it
|
|
269
|
+
covers a call is `covers`' answer, never this one's."""
|
|
270
|
+
if agent_id is None or mandate_from is None or not isinstance(request.body, str):
|
|
271
|
+
return None, None
|
|
272
|
+
sender = request.sender if isinstance(request.sender, dict) else {}
|
|
273
|
+
principal = sender.get("principal") if isinstance(sender.get("principal"), dict) else {}
|
|
274
|
+
if principal.get("provenance") != "trusted":
|
|
275
|
+
return None, None
|
|
276
|
+
if not isinstance(sender.get("project"), str) or not sender["project"]: # comms-message requires it; the caller
|
|
277
|
+
return None, None
|
|
278
|
+
if mandate_from != PERSONAL_BOARD and _fold(sender["project"]) not in mandate_from:
|
|
279
|
+
return None, None
|
|
280
|
+
found = []
|
|
281
|
+
for block in _json_blocks(request.body):
|
|
282
|
+
try:
|
|
283
|
+
value = json.loads(block, parse_constant=_no_constant, parse_int=_bounded_int)
|
|
284
|
+
except (ValueError, RecursionError):
|
|
285
|
+
if "mandate/1" in block:
|
|
286
|
+
return None, "mandate-unreadable"
|
|
287
|
+
continue
|
|
288
|
+
if isinstance(value, dict) and value.get("kind") == "mandate/1":
|
|
289
|
+
value.pop("source", None) # a Project declares no protocols: a board block carries no source (OA-7)
|
|
290
|
+
found.append(value)
|
|
291
|
+
if len(found) > 1:
|
|
292
|
+
return None, "mandate-blocks"
|
|
293
|
+
return (found[0], None) if found else (None, None)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
CALL_MEMBERS = ("capability", "action", "effect", "spendUsd", "project") # what mandate_covers reads of a call
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def covers(mandate: Optional[Dict[str, Any]], call: Any, agent_id: Optional[str], wall: Callable[[], float],
|
|
300
|
+
caller: Any = None) -> Optional[Dict[str, Any]]:
|
|
301
|
+
"""`mandate_covers` for a call under a board request's mandate, its sender placed as T1 and no caller agent id (the
|
|
302
|
+
board names a Project's principal, not an agent the chain can bind; the chain's end alone binds it, MD-3). A covered
|
|
303
|
+
answer's `recordAs` names `caller`, the sender Project the board verified (MD-3 records the caller, as the action
|
|
304
|
+
path does). None when no mandate travelled: the handler asks the operator as it would without one. The call's
|
|
305
|
+
`spendUsd` and `project` are read failing closed, as the kit's other mandate readers read them (FAA-139): a spend
|
|
306
|
+
that is not a finite number is none, which a ceiling refuses; a `project` that is not a string, None included (the
|
|
307
|
+
contract's mandateCovers refuses a null project), is unreadable, which a mandate limited to projects refuses; a call
|
|
308
|
+
that names none leaves `project` out. Only the five members `mandate_covers` reads are read, each once, the way the
|
|
309
|
+
handler's own `call[name]` reads it (a `__getitem__` or a default answers, never the mapping's storage), and the
|
|
310
|
+
rest of the call is never touched."""
|
|
311
|
+
if mandate is None or agent_id is None:
|
|
312
|
+
return None
|
|
313
|
+
if isinstance(call, Mapping):
|
|
314
|
+
read: Dict[str, Any] = {}
|
|
315
|
+
for name in CALL_MEMBERS:
|
|
316
|
+
try:
|
|
317
|
+
value = call[name]
|
|
318
|
+
except KeyError: # not named; any other error the mapping raises passes to the handler
|
|
319
|
+
continue
|
|
320
|
+
if name == "spendUsd":
|
|
321
|
+
value = _rules.mandate_spend(value)
|
|
322
|
+
if value is None:
|
|
323
|
+
continue
|
|
324
|
+
elif name == "project":
|
|
325
|
+
value = value if type(value) is str else _rules.UNREADABLE_PROJECT
|
|
326
|
+
read[name] = value
|
|
327
|
+
call = read
|
|
328
|
+
clock, now = wall(), ""
|
|
329
|
+
if isinstance(clock, (int, float)) and not isinstance(clock, bool):
|
|
330
|
+
try:
|
|
331
|
+
now = _format_rfc3339(clock * 1000) if math.isfinite(clock) else ""
|
|
332
|
+
except (OverflowError, ValueError): # finite but outside the dates a clock can name
|
|
333
|
+
now = ""
|
|
334
|
+
answer = _mandate.mandate_covers({"mandate": mandate, "tier": "T1", "now": now, "self": agent_id, "call": call})
|
|
335
|
+
if "recordAs" in answer:
|
|
336
|
+
answer = {"ok": True, "recordAs": dict(answer["recordAs"], caller=caller)}
|
|
337
|
+
return answer
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def mandate_options(agent_id: Any, mandate_from: Any) -> Any:
|
|
341
|
+
"""The responder's opt-in, checked: an agent id the registry accepts, and `mandate_from` either `PERSONAL_BOARD` or
|
|
342
|
+
a collection of Project ids (strings). Answers `mandate_from` as the responder keeps it (a frozenset or the string)."""
|
|
343
|
+
if agent_id is not None and not _mandate.is_agent_id(agent_id):
|
|
344
|
+
raise BoardError("invalid_arguments", "agent_id must be the service's registry agent id.")
|
|
345
|
+
if mandate_from is None or mandate_from == PERSONAL_BOARD:
|
|
346
|
+
return mandate_from
|
|
347
|
+
try:
|
|
348
|
+
projects = None if isinstance(mandate_from, (str, bytes, Mapping)) else list(mandate_from)
|
|
349
|
+
except TypeError:
|
|
350
|
+
projects = None
|
|
351
|
+
if projects is None or not all(isinstance(p, str) and p for p in projects):
|
|
352
|
+
raise BoardError("invalid_arguments", "mandate_from is %r or a collection of Project ids." % PERSONAL_BOARD)
|
|
353
|
+
return frozenset(_fold(p) for p in projects)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _fold(project: str) -> str:
|
|
357
|
+
"""A Project id as Fabric's board compares it: a uuid lower-cased, any other id as written (_hub._project)."""
|
|
358
|
+
return project.lower() if _hub.UUID.fullmatch(project) else project
|