@passioncode-ai/fabric-agent-adapter 0.82.0 → 0.83.1

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 (114) hide show
  1. package/CHANGELOG.md +122 -3
  2. package/README.md +6 -3
  3. package/SECURITY.md +4 -3
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -2
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +16 -0
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +37 -12
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +21 -148
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -8
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +3 -3
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +20 -9
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +5 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +8 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +26 -5
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -2
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +21 -7
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +6 -2
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +15 -2
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +55 -19
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +15 -2
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +27 -6
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +31 -7
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +18 -16
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +9 -5
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +15 -6
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +93 -93
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +37 -12
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +365 -53
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
  97. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
  98. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
  99. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
  100. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
  101. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
  102. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
  103. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
  104. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
  105. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  106. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  107. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
  108. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +14 -9
  109. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +7 -4
  110. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +16 -5
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +79 -8
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
5
5
  import { Backoff, HubError, normalProject } from './fabric-hub.mjs';
6
6
  import { PRIMARY_CAPABILITY, 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, PERSONAL_BOARD, covers, mandateOptions, readMandate } from './fabric-board-link.mjs';
8
+ import { BoardRequest, BoardSession, Checkpoint, PERSONAL_BOARD, covers, mandateOptions, noticeLinks, readBackProblem, readMandate, senderAgent } 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
11
  export { BoardRequest, BoardSession, Checkpoint, PERSONAL_BOARD }; // re-exported: callers import them from here since 0.40.0
@@ -20,6 +20,8 @@ export const POLL_JITTER_SECONDS = 5;
20
20
  export const DRAIN_SECONDS = 10;
21
21
  /** An idle responder's leave waits this long for the DELETE, so an idle stop ends within LC-01's 5 s (FAA-173). */
22
22
  export const IDLE_LEAVE_SECONDS = 3;
23
+ /** `board.down` is said once per outage window: said again only after the board stayed up this long (FAA-187 (22)). */
24
+ export const DOWN_QUIET_SECONDS = 300;
23
25
 
24
26
  export const EVENT_SCHEMA = 'urn:fabric:schema:board-responder-event:1';
25
27
 
@@ -35,16 +37,18 @@ export class WorkContext {
35
37
  #responder;
36
38
  #entry;
37
39
  #caller;
38
- constructor(responder, entry, mandate = null, caller = null) {
40
+ #agent;
41
+ constructor(responder, entry, mandate = null, caller = null, callerAgent = null) {
39
42
  this.#responder = responder;
40
43
  this.#entry = entry;
41
44
  this.#caller = caller;
45
+ this.#agent = callerAgent; // FAA-135: the sender's agent id identifyAgent named, binding the mandate's chain
42
46
  this.stopped = false;
43
47
  this.mandate = mandate; // the operator's mandate the request carried, when the responder reads one (FAA-135)
44
48
  }
45
49
  /** Whether the request's mandate covers `call` (`mandateCovers`' answer); null when none travelled. */
46
50
  mandateCovers(call) {
47
- return covers(this.mandate, call, this.#responder.agentId, this.#responder.wall, this.#caller);
51
+ return covers(this.mandate, call, this.#responder.agentId, this.#responder.wall, this.#caller, this.#agent);
48
52
  }
49
53
  toJSON() {
50
54
  return { stopped: this.stopped }; // never the mandate: it holds the operator's words
@@ -82,33 +86,39 @@ function noticeSubject(value, project, episode) {
82
86
  */
83
87
  export class Responder extends SlotKeeper {
84
88
  constructor({ hub, projectId, capabilities, credentialFile, checkpointFile, handler, admit, observe, emit, service = 'service',
85
- now = () => performance.now() / 1000, rand = Math.random, sessionFactory, agentId = null, mandateFrom = null, wall = Date.now } = {}) {
89
+ now = () => performance.now() / 1000, rand = Math.random, sessionFactory, agentId = null, mandateFrom = null, wall = Date.now, identifyAgent = null, links = null } = {}) {
86
90
  super();
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.');
91
+ if (typeof admit !== 'function' || typeof handler !== 'function' || !(identifyAgent == null || typeof identifyAgent === 'function')) {
92
+ throw new BoardError('invalid_arguments', 'A responder needs the service\'s own input gate (admit) and a handler; identifyAgent, when given, is a function of the sender (FAA-135).');
93
+ }
88
94
  const caps = [...(capabilities ?? [])];
89
95
  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}.');
90
96
  // FAA-244: DEC-0140 PR-1, a session's slot, never a service's
91
97
  if (caps.includes(PRIMARY_CAPABILITY)) throw new BoardError('invalid_arguments', `${PRIMARY_CAPABILITY} is a Project's primary responder's, a session's: a service answers the capabilities it declares (DEC-0140 PR-1).`);
92
98
  Object.assign(this, { hub, capabilities: caps, credentialFile, handler, admit, observe, emitTo: emit, now, rand });
93
99
  this.project = normalProject(projectId);
94
- // FAA-135: read the operator's mandate from a trusted request of his own Project (opt-in; null reads none)
95
- Object.assign(this, { mandateFrom: mandateOptions(agentId, mandateFrom), agentId: agentId ?? null, wall });
100
+ // FAA-135: read the operator's mandate from a trusted request of his own Project (opt-in; null reads none), and bind
101
+ // its chain with the agent id identifyAgent names for the sender (asked only when a mandate travelled)
102
+ Object.assign(this, { mandateFrom: mandateOptions(agentId, mandateFrom), agentId: agentId ?? null, wall, identifyAgent: identifyAgent ?? null });
103
+ this.links = noticeLinks(links); // FAA-187 (21): the page that resolves each notify kind
96
104
  this.service = String(service).replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 40) || 'service';
97
105
  this.checkpoint = new Checkpoint(checkpointFile);
98
106
  this.sessionFactory = sessionFactory ?? ((grant) => new BoardSession(grant));
99
107
  this.backoff = new Backoff({ rand });
100
108
  this.session = null;
101
109
  this.epoch = null;
102
- this.slots = {};
110
+ // FAA-196 (24): maps keyed by a capability's name have no prototype, so `constructor` is a name like any other
111
+ this.slots = Object.create(null);
103
112
  this.current = null;
104
113
  this.state = 'idle';
105
114
  this.waitUntil = 0;
106
115
  this.stopping = false;
107
116
  /** Whether shutdown found work to drain, published before it waits; null before shutdown starts (FAA-186). */
108
117
  this.draining = null;
109
- this.claimKeys = {};
118
+ this.claimKeys = Object.create(null);
110
119
  this.probing = new Set(); // moved slots whose move-back claim lost its answer: asked again under the same key
111
- this.said = {};
120
+ this.said = Object.create(null);
121
+ this.upSince = null; // when the board was last seen up after a fault (FAA-187 (22))
112
122
  this.rr = 0;
113
123
  this.reconcileAt = 0;
114
124
  /** The first pause under an operator's hold (DEC-0079); it doubles to HOLD_RETRY_MAX_SECONDS. */
@@ -118,7 +128,7 @@ export class Responder extends SlotKeeper {
118
128
  this.fenceQueue = Promise.resolve();
119
129
  this.toResume = []; // transferred requests waiting until nothing runs (finding 39)
120
130
  this.suspect = new Set(); // capabilities a fenced call was refused on: com.status says whether they moved (FAA-155)
121
- this.confirmed = {}; // this session's: the generation it held, or com.status showed unmoved
131
+ this.confirmed = Object.create(null); // this session's: the generation it held, or com.status showed unmoved
122
132
  }
123
133
 
124
134
  /** Runs fn after every fenced call queued before it (finding 36): an await inside a call never lets another in. */
@@ -148,7 +158,8 @@ export class Responder extends SlotKeeper {
148
158
  // (events-and-notifications.md, "Say who wants what"; 0.56.0 seam r30).
149
159
  const subject = noticeSubject(value, this.project, episode);
150
160
  try {
151
- this.emitTo(kind, level, text, { notify, subject, ...(data ? { data } : {}) });
161
+ const link = notify && this.links && Object.hasOwn(this.links, kind) ? { link: this.links[kind] } : {}; // the page that resolves it (FAA-187 (21))
162
+ this.emitTo(kind, level, text, { notify, subject, ...link, ...(data ? { data } : {}) });
152
163
  } catch {
153
164
  // an event that cannot be written never stops the responder
154
165
  }
@@ -190,8 +201,8 @@ export class Responder extends SlotKeeper {
190
201
 
191
202
  async connect() {
192
203
  this.state = 'connecting';
193
- this.slots = {}; // the previous session's slots are not this one's: a refusal now reconnects (FAA-163)
194
- this.confirmed = {}; // nor its confirmations: a slot may have moved between the two sessions
204
+ this.slots = Object.create(null); // the previous session's slots are not this one's: a refusal now reconnects (FAA-163)
205
+ this.confirmed = Object.create(null); // nor its confirmations: a slot may have moved between the two sessions
195
206
  this.probing = new Set(); // nor its lost move-back claims
196
207
  const grant = await this.hub.joinBoard(this.project, this.credentialFile);
197
208
  const missing = this.capabilities.filter((c) => !grant.capabilities.includes(c));
@@ -202,10 +213,10 @@ export class Responder extends SlotKeeper {
202
213
  if (missing.length) {
203
214
  this.emit('board.access.needed', 'warning', `The operator has not let this service answer ${missing.join(', ')} on this Project; ask in Fabric (Settings → Agent access).`, { notify: true, episode: missing.join(','), capabilities: missing });
204
215
  }
205
- this.slots = {};
216
+ this.slots = Object.create(null);
206
217
  await this.recover(status, this.capabilities.filter((c) => grant.capabilities.includes(c)));
207
218
  this.state = 'ready';
208
- for (const kind of ['board.down', 'board.blocked', ...(missing.length ? [] : ['board.access.needed'])]) delete this.said[kind];
219
+ for (const kind of ['board.blocked', ...(missing.length ? [] : ['board.access.needed'])]) delete this.said[kind]; // board.down waits for its quiet window (step)
209
220
  }
210
221
 
211
222
  async enroll(capability) {
@@ -262,7 +273,7 @@ export class Responder extends SlotKeeper {
262
273
  }
263
274
 
264
275
  async recover(status, capabilities) {
265
- const transfer = {};
276
+ const transfer = Object.create(null);
266
277
  const reconcile = [];
267
278
  for (const entry of Object.values(this.checkpoint.entries)) {
268
279
  if (!capabilities.includes(entry.capability)) continue;
@@ -281,7 +292,7 @@ export class Responder extends SlotKeeper {
281
292
  else reconcile.push(entry);
282
293
  }
283
294
  const responders = (status.responders ?? []).filter(isObj);
284
- const generations = Object.fromEntries(responders.map((r) => [r.capability, r.generation ?? 0]));
295
+ const generations = Object.assign(Object.create(null), Object.fromEntries(responders.map((r) => [r.capability, r.generation ?? 0])));
285
296
  const onHold = new Set(responders.filter((r) => r.held === true).map((r) => r.capability)); // DEC-0079
286
297
  let failure = null;
287
298
  for (const capability of capabilities) {
@@ -470,10 +481,9 @@ export class Responder extends SlotKeeper {
470
481
  if (!this.session) await this.connect();
471
482
  await this.tick();
472
483
  this.backoff.reset();
473
- if (this.state === 'down') { // a lost call was sent again and answered: the session held throughout
474
- this.state = 'ready';
475
- delete this.said['board.down'];
476
- }
484
+ if (this.state === 'down') this.state = 'ready'; // a lost call was sent again and answered: the session held throughout
485
+ this.upSince ??= now;
486
+ if (now - this.upSince >= DOWN_QUIET_SECONDS) delete this.said['board.down']; // up a whole window: a later outage is said again
477
487
  return this.interval();
478
488
  } catch (e) {
479
489
  if (!(e instanceof BoardError) && !(e instanceof HubError)) throw e;
@@ -492,6 +502,7 @@ export class Responder extends SlotKeeper {
492
502
 
493
503
  async fault(e) {
494
504
  const code = e.code ?? 'unreachable';
505
+ this.upSince = null;
495
506
  const connecting = this.state === 'connecting' && !Object.values(this.slots).some((s) => s?.state === 'held');
496
507
  let wait = this.backoff.next();
497
508
  const left = this.leaseLeft() - LEASE_MARGIN_SECONDS;
@@ -554,9 +565,10 @@ export class Responder extends SlotKeeper {
554
565
  await this.gone(capability);
555
566
  this.suspect.delete(capability);
556
567
  }
568
+ const shared = {}; // one com.status read a tick for every moved slot (FAA-194 (5))
557
569
  for (const [capability, slot] of Object.entries(this.slots)) {
558
570
  if (slot.state === 'moved') { // never enrolled again on its own, even when the new holder lapses (FAA-155)
559
- if (this.now() - (slot.renewedAt ?? 0) >= RENEW_SECONDS - 0.5) await this.movedBack(capability); // unless com.status shows a later generation it may hold (FAA-165)
571
+ if (this.now() - (slot.renewedAt ?? 0) >= RENEW_SECONDS - 0.5) await this.movedBack(capability, shared); // unless com.status shows a later generation it may hold (FAA-165)
560
572
  continue;
561
573
  }
562
574
  if (slot.state === 'paused') {
@@ -581,7 +593,7 @@ export class Responder extends SlotKeeper {
581
593
  }
582
594
 
583
595
  async continueLapsed() {
584
- const lapsed = {};
596
+ const lapsed = Object.create(null);
585
597
  for (const entry of Object.values(this.checkpoint.entries)) {
586
598
  if (entry.step === 'lapsed' && this.slots[entry.capability]?.state === 'held') (lapsed[entry.capability] ??= []).push(JSON.parse(JSON.stringify(entry)));
587
599
  }
@@ -648,13 +660,13 @@ export class Responder extends SlotKeeper {
648
660
  revision: receipt.request.revision, step: 'claimed', keys: {}, effect: false };
649
661
  this.checkpoint.put(entry);
650
662
  const message = await this.call('com.get', { message: entry.message });
651
- const req = isObj(message) ? message.request : null;
652
- if (message.digest !== entry.digest) {
663
+ const problem = readBackProblem(message, entry, this.project);
664
+ if (problem === 'digest-mismatch') {
653
665
  this.checkpoint.drop(entry.request);
654
666
  this.emit('board.request.skipped', 'warning', 'A claimed request was not accepted: the message read back carries another digest than the claim. Nothing was done; the claim lapses.', { request: entry.request, capability, reason: 'digest-mismatch' });
655
667
  return;
656
668
  }
657
- if (!isObj(req) || req.id !== entry.request || req.capability !== capability || req.target !== this.project) {
669
+ if (problem) {
658
670
  this.checkpoint.drop(entry.request);
659
671
  this.emit('board.request.skipped', 'warning', 'A claimed request was not accepted: it is not addressed to this Project and capability. Nothing was done; the claim lapses.', { request: entry.request, capability, reason: 'not-addressed' });
660
672
  return;
@@ -701,12 +713,14 @@ export class Responder extends SlotKeeper {
701
713
  return true;
702
714
  }
703
715
 
704
- async runWork(entry, request) {
705
- const { mandate, unread } = readMandate(request, this.mandateFrom, this.agentId);
716
+ async runWork(entry, request, problem = null) {
717
+ let { mandate, unread } = readMandate(request, this.mandateFrom, this.agentId);
718
+ if (problem && (mandate !== null || unread)) [mandate, unread] = [null, problem]; // FAA-196 (1): another message than accepted
706
719
  if (unread) { // by its code alone: the block holds the operator's words
707
720
  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 });
708
721
  }
709
- const ctx = new WorkContext(this, entry, mandate, isObj(request.sender) ? request.sender.project ?? null : null);
722
+ const sender = isObj(request.sender) ? request.sender : {};
723
+ const ctx = new WorkContext(this, entry, mandate, sender.project ?? null, senderAgent(this.identifyAgent, sender, mandate));
710
724
  const work = { entry, request, ctx, done: false, outcome: null, failure: null, renewedAt: this.now() };
711
725
  this.current = work;
712
726
  work.promise = Promise.resolve().then(() => this.handler(request, ctx)).then((outcome) => { work.outcome = outcome; }, (e) => { work.failure = e; }).finally(() => { work.done = true; });
@@ -721,7 +735,7 @@ export class Responder extends SlotKeeper {
721
735
  return;
722
736
  }
723
737
  const message = await this.call('com.get', { message: entry.message });
724
- await this.runWork(entry, new BoardRequest(message, entry.capability));
738
+ await this.runWork(entry, new BoardRequest(message, entry.capability), readBackProblem(message, entry, this.project));
725
739
  }
726
740
 
727
741
  /**
@@ -131,7 +131,7 @@ export class SlotKeeper {
131
131
  delete this.confirmed[capability];
132
132
  delete this.claimKeys[capability]; // the move returned that claim to the queue: its receipt is dead
133
133
  if (!('moved' in held)) { // once, even across restarts
134
- this.emit('board.slot.moved', 'warning', `The ${capability} slot on this Project moved to another session. This service stopped answering it; accepted work stays \`accepted\` (work begun is the new holder's). The operator brings it back by take-back (once the other session lets it go, or at a restart with accepted work waiting; a restart alone does not) or, where Fabric offers it, a move back here. Else ask its maintainer.`, { notify: true, episode: capability, capability, generation: now, held: held.generation });
134
+ this.emit('board.slot.moved', 'warning', `The ${capability} slot on this Project moved to another session. This service stopped answering it; accepted work stays accepted (work begun is the new holder's). The operator brings it back by take-back (once the other session lets it go, or at a restart or reconnect with accepted work waiting; neither alone does) or, where Fabric can, a move back here. Else ask its maintainer.`, { notify: true, episode: capability, capability, generation: now, held: held.generation });
135
135
  this.note(capability, { ...held, moved: now }); // said again at the next start if it cannot be written
136
136
  }
137
137
  return true;
@@ -143,14 +143,21 @@ export class SlotKeeper {
143
143
  * com.claim asks whether the slot is its own. A claim that lands makes it held again and starts what it handed out; a
144
144
  * lease_expired one (the move reached this session but its lease lapsed) enrolls again; a fenced one is another
145
145
  * session's, recorded so the same generation is not asked about twice. Never an enroll or a replace on a slot another
146
- * session holds: a slot moved off stays off unless it is handed back.
146
+ * session holds: a slot moved off stays off unless it is handed back. `shared` holds the tick's one com.status read,
147
+ * shared by every moved slot (FAA-194 (5)).
147
148
  */
148
- async movedBack(capability) {
149
+ async movedBack(capability, shared = null) {
149
150
  if (this.current || this.stopping) return; // a claim now could hand out a second request, or start work while the responder stops
150
151
  const held = this.checkpoint.slots[capability] ?? {};
151
152
  if (held.project !== this.project || !isInt(held.moved, 1)) return;
152
153
  if (this.slots[capability]) this.slots[capability].renewedAt = this.now();
153
- const now = ((await this.status()).responders ?? []).find((r) => isObj(r) && r.capability === capability)?.generation;
154
+ let status;
155
+ if (shared && 'status' in shared) status = shared.status;
156
+ else {
157
+ status = await this.status();
158
+ if (shared) shared.status = status;
159
+ }
160
+ const now = (status.responders ?? []).find((r) => isObj(r) && r.capability === capability)?.generation;
154
161
  if (!isInt(now, 1) || now <= held.moved) return;
155
162
  if (!this.probing.has(capability) || !this.claimKeys[capability]) { // a fresh key: one from before the move may hold a dead receipt
156
163
  this.claimKeys[capability] = this.key('claim');
@@ -201,14 +208,17 @@ export class SlotKeeper {
201
208
  }
202
209
 
203
210
  /** The slot is this session's again at `generation`: held, its record rewritten without the move (FAA-165). */
204
- back(capability, held, generation) {
205
- return this.locked(() => {
211
+ async back(capability, held, generation) {
212
+ const back = await this.locked(() => {
206
213
  if (!sameRecord(this.checkpoint.slots[capability], held)) return false; // a takeBack forgot it; its own next tick enrolls
207
214
  this.slots[capability] = { state: 'held', generation, renewedAt: -Infinity }; // no lease yet
208
215
  this.heldAt(capability, generation);
209
216
  delete this.said['board.slot.moved']; // a later move is said again
210
217
  return true;
211
218
  });
219
+ // FAA-194 (8): the feed no longer ends on the move's warning
220
+ if (back) this.emit('board.slot.returned', 'info', `The ${capability} slot on this Project is this service's again: the operator moved it back here, and it answers it from now on.`, { capability, generation });
221
+ return back;
212
222
  }
213
223
 
214
224
  /**
@@ -88,7 +88,28 @@ function shapeProblems(value) {
88
88
  // The contract's KEY_SHAPE (src/connections.ts): a provider key where it starts a word, case-sensitive prefixes, the
89
89
  // `sk-` rules and a Telegram bot token.
90
90
  const KEY_SHAPE = /(?:^|[^A-Za-z0-9])(?:sk-(?=[A-Za-z0-9_-]*\d)(?:(?=[A-Za-z0-9_-]*[A-Z])[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{32,})|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}|(?:bot)?\d{6,12}:(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[a-z])[A-Za-z0-9_-]{34,35}(?![A-Za-z0-9_-]))/;
91
- const SCHEME = /[a-z][a-z0-9+.-]*:\/\//gi;
91
+ const SCHEME_CHAR = /[a-z0-9+.-]/i;
92
+ const SCHEME_LETTER = /[a-z]/i;
93
+ /**
94
+ * The [start, end] of each URL scheme and its `://` in a piece, where `/[a-z][a-z0-9+.-]*:\/\//gi` finds them left to
95
+ * right, read in linear time (FAA-322: the pattern tried every letter of a long run with no `://`). From each `://` the
96
+ * run of scheme characters before it is read back, and the match starts at its first letter at or after where the last
97
+ * match ended. The twin of fabric_connections._scheme_spans.
98
+ */
99
+ export function schemeSpans(piece) {
100
+ const spans = [];
101
+ let after = 0;
102
+ for (let at = piece.indexOf('://'); at >= 0; at = piece.indexOf('://', at + 1)) {
103
+ let start = at;
104
+ while (start > after && SCHEME_CHAR.test(piece[start - 1])) start -= 1;
105
+ while (start < at && !SCHEME_LETTER.test(piece[start])) start += 1;
106
+ if (start < at) {
107
+ spans.push([start, at + 3]);
108
+ after = at + 3;
109
+ }
110
+ }
111
+ return spans;
112
+ }
92
113
  const CLOSING = /[)\]}>"'.,;:!?]/;
93
114
  const TRIED_BACK = 1;
94
115
  /** The URLs a member may hold, from every scheme in each whitespace-delimited piece, without the closing brackets,
@@ -96,8 +117,8 @@ const TRIED_BACK = 1;
96
117
  function urlsIn(value) {
97
118
  const urls = [];
98
119
  for (const piece of value.split(/\s+/)) {
99
- for (const match of piece.matchAll(SCHEME)) {
100
- const url = piece.slice(match.index);
120
+ for (const [start] of schemeSpans(piece)) {
121
+ const url = piece.slice(start);
101
122
  let end = url.length;
102
123
  while (end > 0 && CLOSING.test(url[end - 1])) end -= 1;
103
124
  for (let back = 0; back <= TRIED_BACK && end + back <= url.length; back += 1) urls.push(url.slice(0, end + back));
@@ -108,8 +129,8 @@ function urlsIn(value) {
108
129
  /** A long secret-shaped run in a piece read whole; in a piece that holds a URL, the text before its first scheme's `://`. */
109
130
  const longRun = (value) =>
110
131
  value.split(/\s+/).some((piece) => {
111
- const at = piece.search(SCHEME);
112
- const cut = at < 0 ? -1 : piece.indexOf('://', at);
132
+ const found = schemeSpans(piece);
133
+ const cut = found.length ? found[0][1] - 3 : -1;
113
134
  return longSecret(cut < 0 ? piece : piece.slice(0, cut));
114
135
  });
115
136
  // hasJwt reads a JWT in one pass, where JWT_SHAPE backtracks from every `eyJ` of a long run (FAA-313).
@@ -15,10 +15,10 @@
15
15
  import crypto from 'node:crypto';
16
16
  import fs from 'node:fs';
17
17
  import http from 'node:http';
18
- import net from 'node:net';
19
18
  import { hubKeyCertified } from './fabric-estate.mjs';
20
19
  import { atomicWrite } from './fabric-paths.mjs'; // the kit's private writer (FAA-85)
21
20
  import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs'; // a loopback key set from this account's listener only (FAA-255)
21
+ import { loopbackHost } from './fabric-socket-proof.mjs';
22
22
  import { effectClosure } from './fabric-mandate.mjs'; // defined once beside the mandate that reads it too (DEC-0125)
23
23
 
24
24
  export const REFUSAL_CODES = Object.freeze([
@@ -413,7 +413,8 @@ export function readDelegationToken(headers) {
413
413
  /** The digest an approval binds: sha256: and the hex SHA-256 of the action's RFC 8785 form. */
414
414
  export const actionDigest = (action) => `sha256:${crypto.createHash('sha256').update(jcs(action), 'utf8').digest('hex')}`;
415
415
 
416
- const loopback = (host) => host === 'localhost' || (net.isIP(host.replace(/^\[|\]$/g, '')) !== 0 && (/^127\./.test(host) || host.replace(/^\[|\]$/g, '') === '::1'));
416
+ // A key set source's loopback host: the kit's one rule, shared with fabric-http's checkUrl (FAA-187 (15)).
417
+ const loopback = loopbackHost;
417
418
 
418
419
  const KEY_SET_MAX = 65536;
419
420
  const KEY_SET_AGENT = new CheckedAgent({ keepAlive: false });
@@ -12,13 +12,13 @@ import { protocolsFindings } from './fabric-protocols.mjs';
12
12
  import { CONNECTIONS_EXTENSION_KEY, connectionsView } from './fabric-connections.mjs';
13
13
  import { ACTIONS_EXTENSION_KEY, actionsExtension, actionsExtensionRules } from './fabric-actions.mjs';
14
14
  import { a2aSurfaceProblems } from './fabric-a2a.mjs';
15
- import { ROUTES } from './fabric-usage.mjs';
15
+ import { ROUTES, isPlainObject } from './fabric-usage.mjs';
16
16
 
17
17
  // The embedded dashboard declaration (fabric-dashboard/0.1, DEC-0036 ED-9), under the well-known
18
18
  // document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
19
19
  export const DASHBOARD_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1';
20
20
 
21
- export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.7';
21
+ export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.8';
22
22
 
23
23
  export const REFERENCE_THEMES = ['graphite', 'fabric', 'paper', 'forest', 'ocean', 'ember', 'contrast'];
24
24
 
@@ -47,7 +47,8 @@ export function readDescriptors(dir = servicesDir()) {
47
47
  if (!fs.existsSync(dir)) return [];
48
48
  return fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort().flatMap((n) => {
49
49
  try {
50
- return [[path.join(dir, n), JSON.parse(fs.readFileSync(path.join(dir, n), 'utf8'))]];
50
+ const value = JSON.parse(fs.readFileSync(path.join(dir, n), 'utf8'));
51
+ return isPlainObject(value) ? [[path.join(dir, n), value]] : []; // no object, no descriptor (FAA-200 (50))
51
52
  } catch {
52
53
  return [];
53
54
  }
@@ -66,14 +67,16 @@ export function validateDescriptor(d) {
66
67
  }
67
68
  if (problems.length) return problems;
68
69
  if (d.protocol !== PROTOCOL) problems.push(`protocol must be ${PROTOCOL}`);
69
- if (!ID.test(d.id)) problems.push(`id must match ${ID}`);
70
- if (!INSTANCE.test(d.instance)) problems.push(`instance must match ${INSTANCE}`);
70
+ for (const [key, pattern] of [['id', ID], ['instance', INSTANCE]]) { // a string before its pattern (FAA-200 (9), (36))
71
+ if (typeof d[key] !== 'string') problems.push(`${key} must be a string`);
72
+ else if (!pattern.test(d[key])) problems.push(`${key} must match ${pattern}`);
73
+ }
71
74
  if (remote) problems.push(...remoteOriginProblems(d.origin));
72
75
  else if (d.origin !== undefined && !ORIGIN.test(d.origin)) problems.push('origin must be http://127.0.0.1:<port>');
73
76
  if (!d.auth?.tokenFile) problems.push('auth.tokenFile is required');
74
- else if (!LOCAL_PATH.test(String(d.auth.tokenFile))) problems.push('auth.tokenFile must be a local path, never a network share');
77
+ else if (!LOCAL_PATH.test(String(d.auth.tokenFile))) problems.push('auth.tokenFile must be an absolute local path (/, ~/ or a drive letter), never a relative path, never a network share');
75
78
  for (const value of [...(d.paths?.data !== undefined ? [d.paths.data] : []), ...(d.paths?.logs ?? [])]) {
76
- if (!LOCAL_PATH.test(String(value))) problems.push(`paths must be local paths, never a network share: ${JSON.stringify(value)}`);
79
+ if (!LOCAL_PATH.test(String(value))) problems.push(`paths must be absolute local paths (/, ~/ or a drive letter), never a relative path, never a network share: ${JSON.stringify(value)}`);
77
80
  }
78
81
  if (!(typeof (d.auth?.header ?? 'Authorization') === 'string' && AUTH_HEADER.test(d.auth?.header ?? 'Authorization'))) problems.push(`auth.header must match ${AUTH_HEADER.source}`);
79
82
  if (!['Bearer', 'none'].includes(d.auth?.scheme ?? 'Bearer')) problems.push('auth.scheme is Bearer or none');
@@ -198,7 +201,7 @@ export function descriptorMemberProblems(d) {
198
201
  else if (URI_PASSWORD.test(repository)) problems.push('source.repository carries no password: it names the repository, never a credential'); // before the host
199
202
  else if (!authorityOk(repository)) problems.push('source.repository names a host, and a port of digits up to 65535 when it has one');
200
203
  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
201
- 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)
204
+ else if (repositoryCredential(repository)) problems.push("FAC-SEM-086: /source/repository holds a value shaped like a credential; it names a repository: write the repository's name in lower case if it is one"); // its name is no key (seam 6)
202
205
  }
203
206
  }
204
207
  if ('peer' in d) {
@@ -428,15 +431,18 @@ export function portListenerRefusal(port, reader = portOwner) {
428
431
  * account listens on (FAA-225; `portOwner(port) -> [kind, uid] | null` reads it, the kit's own by default).
429
432
  * FAA-198: a new descriptor names its source.repository; rewriting one already installed without it only warns
430
433
  * (process.emitWarning, code FABRIC_NO_SOURCE_REPOSITORY), so an installed agent's update keeps working. */
431
- export function writeDescriptor(d, dir = servicesDir(), { portOwner: reader = portOwner } = {}) {
434
+ /** The rewrite warning of a descriptor that names no source.repository (FAA-198). */
435
+ export function warnNoSource(d) {
436
+ process.emitWarning(`Descriptor ${d.id}.${d.instance} ${NO_SOURCE_REPOSITORY}; a new install is refused without it.`, { code: 'FABRIC_NO_SOURCE_REPOSITORY' });
437
+ }
438
+
439
+ export function writeDescriptor(d, dir = servicesDir(), { portOwner: reader = portOwner, warned = false } = {}) {
432
440
  const problems = validateDescriptor(d);
433
441
  if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
434
442
  const me = `${d.id}.${d.instance}`;
435
443
  const refusal = sourceRefusal(d, dir);
436
444
  if (refusal) throw new ServiceError(refusal);
437
- if (!namesRepository(d)) {
438
- process.emitWarning(`Descriptor ${me} ${NO_SOURCE_REPOSITORY}; a new install is refused without it.`, { code: 'FABRIC_NO_SOURCE_REPOSITORY' });
439
- }
445
+ if (!namesRepository(d) && !warned) warnNoSource(d); // registerRemote warns itself, before its token (FAA-200 (1))
440
446
  // DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
441
447
  const port = placementOf(d) === 'remote' || d.origin === undefined ? null : portOf(d.origin);
442
448
  for (const [file, other] of readDescriptors(dir)) {
@@ -18,7 +18,12 @@ const TRACE_ID = /^(?!0{32}$)[0-9a-f]{32}$/;
18
18
 
19
19
  const SPAN_ID = /^(?!0{16}$)[0-9a-f]{16}$/;
20
20
 
21
- /** One activity event. `level` is one of LEVELS; `warn`, the dashboard chip's name, is stored as `warning`. `data` (DEC-0070) adds machine-readable fields, `{schema, value}`, never a secret: refused here, so no feed carries one. */
21
+ /**
22
+ * One activity event. `level` is one of LEVELS; `warn`, the dashboard kit's chip for "a person is needed"
23
+ * (`.fd-s-warn`), is stored as `warning`, the feed's name for the same level, so a reader that draws chips shows a
24
+ * stored `warning` as the `warn` chip (FAA-276, FAA-277 (6)). `data` (DEC-0070) adds machine-readable fields,
25
+ * `{schema, value}`, never a secret: refused here, so no feed carries one.
26
+ */
22
27
  export function makeEvent(id, at, kind, given, text, { subject, link, notify, traceId, spanId, data } = {}) {
23
28
  const level = typeof given === 'string' && Object.hasOwn(LEVEL_ALIASES, given) ? LEVEL_ALIASES[given] : given;
24
29
  if (!LEVELS.includes(level)) throw new ServiceError(`level must be one of ${LEVELS.join(', ')} (or \`warn\` for \`warning\`).`);
@@ -2,7 +2,9 @@
2
2
  // the contract's pause grammar (`quota-exhausted[ until <until>]: waiting <reason> since <since>[: <message>]`; the
3
3
  // pinned interop-job schema is closed, so `waiting {reason, since, until, message}` rides statusMessage, never a field
4
4
  // of the job), one push per hold, and the operator's scoped lift. The twin of fabric_holds.py, held to the same
5
- // vectors (test/fixtures/holds/waiting-vectors.json). Part of the Node service kit (layer L3, after fabric-interop).
5
+ // vectors (test/fixtures/holds/waiting-vectors.json). A record written since 0.83.0 carries `squash: 'ecmascript'`; one
6
+ // without it is matched under both squashes, as 0.81.0's Python twin squashed names with str.split() (FAA-320,
7
+ // test/fixtures/holds/legacy-squash-vectors.json). Part of the Node service kit (layer L3, after fabric-interop).
6
8
  // Node 20+, no dependency.
7
9
  import crypto from 'node:crypto';
8
10
  import fs from 'node:fs';
@@ -40,6 +42,17 @@ const reasonOk = (reason, what) => {
40
42
  // (FAA-316): Python's str.split() also takes U+001C to U+001F and U+0085 and not U+FEFF.
41
43
  const SPACES = /[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+/g;
42
44
  const squash = (text) => String(text).replace(SPACES, ' ').replace(/^ | $/g, '');
45
+ /** What a record written since 0.83.0 carries: its names were squashed with ECMAScript's whitespace (FAA-320). */
46
+ export const SQUASH = 'ecmascript';
47
+ // Python's str.isspace() class, the one 0.81.0's Python twin squashed a name with (`" ".join(text.split())`).
48
+ const PY_SPACES = /[\t\n\v\f\r\u001c-\u001f \u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+/g;
49
+ const legacySquash = (text) => String(text).replace(PY_SPACES, ' ').replace(/^ | $/g, '');
50
+ // The stored forms `caller` matches in `record`: the ECMAScript squash, and for a record with no `squash` mark (written
51
+ // before 0.83.0) also the squash 0.81.0's Python twin wrote (FAA-320). null matches only null.
52
+ const names = (caller, record) => {
53
+ if (typeof caller !== 'string') return [caller];
54
+ return record.squash === SQUASH ? [squash(caller)] : [squash(caller), legacySquash(caller)];
55
+ };
43
56
 
44
57
  /** A held job's statusMessage; the message is shortened, never refused, to fit 500 characters. */
45
58
  export function waitingMessage(reason, since, until = null, message = null) {
@@ -149,6 +162,7 @@ export class HoldBook {
149
162
 
150
163
  /** Hold `work` for `caller` by `reason`: {hold, statusMessage, push}; push only when this call opened the hold. */
151
164
  hold({ caller, work, reason, until = null, message = null, jobId = null, now = null }) {
165
+ const rawCaller = caller;
152
166
  caller = text(caller, 'caller');
153
167
  work = text(work, 'work');
154
168
  reasonOk(reason, 'hold');
@@ -158,10 +172,10 @@ export class HoldBook {
158
172
  }
159
173
  const data = this.load();
160
174
  HoldBook.expire(data, utcTime(at));
161
- let hold = data.holds.find((h) => h.closed === null && h.reason === reason && h.caller === caller);
175
+ let hold = data.holds.find((h) => h.closed === null && h.reason === reason && names(rawCaller, h).includes(h.caller));
162
176
  const opened = hold === undefined;
163
177
  if (opened) {
164
- hold = { id: newId(), reason, caller, work, since: at, until, jobs: [], closed: null };
178
+ hold = { id: newId(), reason, caller, work, since: at, until, jobs: [], closed: null, squash: SQUASH };
165
179
  data.holds.push(hold);
166
180
  }
167
181
  if (jobId !== null && !hold.jobs.includes(String(jobId))) hold.jobs.push(String(jobId));
@@ -176,7 +190,7 @@ export class HoldBook {
176
190
  release({ reason, caller, now = null }) {
177
191
  const at = this.now(now);
178
192
  const data = this.load();
179
- const hold = data.holds.find((h) => h.closed === null && h.reason === reason && h.caller === caller);
193
+ const hold = data.holds.find((h) => h.closed === null && h.reason === reason && names(caller, h).includes(h.caller));
180
194
  if (!hold) return null;
181
195
  hold.closed = 'released';
182
196
  hold.closedAt = at;
@@ -211,12 +225,12 @@ export class HoldBook {
211
225
  throw new ServiceError('A raised limit needs its window\'s end (until): it falls back when the window ends.');
212
226
  }
213
227
  const record = { id: newId(), reason, caller: caller === null ? null : text(caller, 'caller'),
214
- job: jobId === null ? null : String(jobId), from: at, until, [kind]: size, decision: { ...decision }, at };
228
+ job: jobId === null ? null : String(jobId), from: at, until, [kind]: size, decision: { ...decision }, at, squash: SQUASH };
215
229
  if (kind !== 'limit') record.left = size;
216
230
  const data = this.load();
217
231
  HoldBook.expire(data, utcTime(at));
218
232
  for (const hold of data.holds) {
219
- if (hold.closed === null && hold.reason === reason && (record.caller === null || record.caller === hold.caller)
233
+ if (hold.closed === null && hold.reason === reason && (caller === null || names(caller, hold).includes(hold.caller))
220
234
  && (jobId === null || hold.jobs.includes(String(jobId)))) {
221
235
  hold.closed = 'lifted';
222
236
  hold.closedAt = at;
@@ -234,7 +248,7 @@ export class HoldBook {
234
248
 
235
249
  static matching(data, reason, caller, jobId, at, kind) {
236
250
  return data.lifts.filter((l) => l.reason === reason && kind in l && HoldBook.live(l, at)
237
- && (l.caller === null || l.caller === caller) && (l.job === null || l.job === jobId));
251
+ && (l.caller === null || names(caller, l).includes(l.caller)) && (l.job === null || l.job === jobId));
238
252
  }
239
253
 
240
254
  /** Whether a lift lets work past the limit: {admitted: true, lift, left} spends one job or `costUsd`, else {admitted: false}. */
@@ -22,6 +22,7 @@
22
22
  import http from 'node:http';
23
23
  import net from 'node:net';
24
24
  import { CheckedAgent, ListenerRefused, TABLE_RESTRICTED, UNKNOWN, method as tableMethod, owner as ownerOf, refusal, serverEnd } from './fabric-peer-owner.mjs';
25
+ import { loopbackHost } from './fabric-socket-proof.mjs';
25
26
 
26
27
  export const RETRY_STATUSES = Object.freeze([408, 425, 429, 500, 502, 503, 504]);
27
28
  export const IDEMPOTENT_METHODS = Object.freeze(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
@@ -78,11 +79,8 @@ export function redactUrl(url) {
78
79
  return `${parts.protocol}//${parts.host}${parts.pathname}`;
79
80
  }
80
81
 
81
- function isLoopback(host) {
82
- const h = host.replace(/^\[|\]$/g, '');
83
- if (h === 'localhost' || h.endsWith('.localhost') || h === '::1') return true;
84
- return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
85
- }
82
+ // Loopback as new URL leaves the host: the kit's one rule, shared with fabric-delegation (FAA-187 (15)).
83
+ const isLoopback = loopbackHost;
86
84
 
87
85
  // Refuse what this client never sends: another scheme, no host, credentials in the URL, plain http off loopback.
88
86
  export function checkUrl(url, { insecure = false } = {}) {
@@ -79,7 +79,8 @@ export function fabricRoot(platform = process.platform, env = process.env) {
79
79
  /**
80
80
  * true: a process of this OS account runs under pid; false: none does. null (falsy): a process of another account does
81
81
  * (EPERM), as when the id hub.json names was reused: not verified, never called running (FAA-304, the session hooks'
82
- * answer since FAA-254). Never signals it.
82
+ * answer since FAA-254). Never signals it. On Windows libuv opens the process for terminate, query and synchronize, so
83
+ * an elevated process of the same user answers EPERM too; the Python twin asks OpenProcess for the same access (FAA-323).
83
84
  */
84
85
  export function pidAlive(pid) {
85
86
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -192,7 +193,7 @@ export function readCredential(file) {
192
193
  * is HubError hub-unavailable (retryable); a listener that is not this OS account's process is HubError foreign-user
193
194
  * (retryable) before the bearer is sent (DEC-0154 HA-2): the connection is checked from the socket table first.
194
195
  */
195
- export function postMcp(origin, body, bearer, { session, timeout = 15000, method = 'POST', protocolVersion, agent = HUB_AGENT } = {}) {
196
+ export function postMcp(origin, body, bearer, { session, timeout = 15000, method = 'POST', protocolVersion, agent = HUB_AGENT, deadline = 0 } = {}) {
196
197
  const m = LOOPBACK_ORIGIN.exec(origin);
197
198
  if (!m) return Promise.reject(new HubError('not-a-hub', 'Only the hub\'s loopback origin is used.'));
198
199
  const payload = method === 'DELETE' ? null : Buffer.from(JSON.stringify(body));
@@ -224,6 +225,9 @@ export function postMcp(origin, body, bearer, { session, timeout = 15000, method
224
225
  res.on('error', () => reject(new HubError('hub-unavailable', 'Fabric\'s hub did not answer (the answer broke off).', true)));
225
226
  });
226
227
  req.on('timeout', () => req.destroy(new Error('timeout')));
228
+ // `deadline` (ms) bounds the whole request, where `timeout` bounds each socket read (FAA-187 (12))
229
+ const timer = deadline > 0 ? setTimeout(() => req.destroy(new Error('deadline')), deadline) : null;
230
+ if (timer) req.on('close', () => clearTimeout(timer));
227
231
  req.on('error', (e) => reject(e instanceof ListenerRefused
228
232
  ? listenerRefusal(e)
229
233
  : new HubError('hub-unavailable', `Fabric's hub did not answer (${e.code ?? e.message}).`, true)));