@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +246 -0
  2. package/README.md +2 -1
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +4 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +1 -1
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +57 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +11 -9
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +8 -1
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +19 -2
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +58 -7
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +4 -2
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -2
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +23 -6
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -1
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +4 -2
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +6 -4
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -2
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +16 -3
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +18 -2
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +33 -3
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +10 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +117 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +24 -5
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -1
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +33 -3
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +14 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +10 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +14 -8
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +24 -4
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +3 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +57 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +16 -1
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +146 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +20 -5
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +55 -4
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +5 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +33 -25
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +19 -6
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +23 -3
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +17 -3
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +86 -7
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +57 -0
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +4 -0
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +217 -29
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +42 -4
  53. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  54. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +23 -23
  55. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +16 -1
  56. 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
- constructor(responder, entry) {
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 ctx = new WorkContext(this, entry);
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; });
@@ -37,6 +37,7 @@ export {
37
37
  BoardRequest,
38
38
  WorkContext,
39
39
  Responder,
40
+ PERSONAL_BOARD,
40
41
  } from './fabric-board-session.mjs';
41
42
  export {
42
43
  selfCheck,
@@ -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
- if (url.username || url.password) throw new Error('a key set URL carries no credentials');
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');
@@ -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
- places.push(['/source/repository', repository]);
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)) {
@@ -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(String(url)); } catch { return '<unparseable 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
- if (parts.username || parts.password || /^[a-z]+:\/*[^/?#]*@/i.test(String(url).trim())) {
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;
@@ -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
- if (!Object.hasOwn(inputRequests ?? {}, key) || !EFFECT_CLASSES.includes(step?.effect)) throw new InteropError(`Step ${key}: an input request's key with its effect class (DEC-0125).`);
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 = Object.entries(inputRequests ?? {});
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: { ...(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 matched = Object.fromEntries(Object.entries(inputResponses ?? {}).filter(([key, value]) =>
285
- key in (job.inputRequests ?? {}) && ['accept', 'decline', 'cancel'].includes(value?.action)));
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;
@@ -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
- fs.ftruncateSync(this.fd, 0);
47
- fs.writeSync(this.fd, String(process.pid));
48
- fs.fsyncSync(this.fd);
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
- atomicWrite(this.path, String(process.pid));
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
 
@@ -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}$/;
@@ -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
+ }
@@ -118,6 +118,9 @@ export {
118
118
  placementOf,
119
119
  remoteOriginProblems,
120
120
  portOf,
121
+ NO_SOURCE_REPOSITORY,
122
+ namesRepository,
123
+ sourceRefusal,
121
124
  writeDescriptor,
122
125
  removeDescriptor,
123
126
  dashboardExtensionProblems,
@@ -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]:
@@ -62,6 +62,7 @@ from fabric_board_session import ( # noqa: F401
62
62
  _Work,
63
63
  Handler,
64
64
  Responder,
65
+ PERSONAL_BOARD,
65
66
  )
66
67
  from fabric_board_fixture import ( # noqa: F401
67
68
  _FIXTURE_PROJECT,
@@ -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