@passioncode-ai/fabric-agent-adapter 0.81.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 (127) hide show
  1. package/CHANGELOG.md +200 -3
  2. package/README.md +11 -6
  3. package/SECURITY.md +8 -5
  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/dashboard-links.md +9 -1
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +25 -2
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +19 -1
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +274 -17
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +24 -151
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
  17. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +3 -16
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +17 -9
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +24 -6
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +14 -4
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +23 -10
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +9 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -2
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +28 -4
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +30 -11
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +19 -1
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +4 -2
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +7 -5
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +22 -11
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +29 -7
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +5 -2
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +27 -10
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +21 -8
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +88 -5
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +133 -30
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +44 -5
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +35 -159
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +43 -11
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +21 -18
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +39 -17
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +92 -10
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +180 -162
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +274 -17
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +465 -55
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
  106. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
  107. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
  111. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
  112. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +9 -1
  113. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
  114. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
  115. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
  116. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
  117. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +19 -1
  118. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +30 -17
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +53 -35
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
  124. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
  125. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +34 -21
  126. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +96 -15
  127. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
@@ -56,8 +56,9 @@ export class HubError extends Error {
56
56
  /**
57
57
  * The HubError for a hub port the peer-owner check refused (DEC-0154 HA-2): code `foreign-user`, the pinned contract's
58
58
  * state name, with the owner's reason in the sentence and in `data.reason` (FAA-272): `table-restricted` when this
59
- * runtime's socket table hides other processes' sockets, which never clears, so it is not retryable; otherwise the
60
- * owner's kind or the check's reason (`another-account`, `no-row`), retryable as before.
59
+ * runtime's socket table hides other processes' sockets, or, on macOS, holds no row for this connection (DEC-0155
60
+ * RT-1, FAA-312), which never clears, so it is not retryable; otherwise the owner's kind or the check's reason
61
+ * (`another-account`, or `no-row`, which only Linux and Windows give), retryable.
61
62
  */
62
63
  export function listenerRefusal(e) {
63
64
  const kind = e?.owner?.kind ?? null;
@@ -75,14 +76,19 @@ export function fabricRoot(platform = process.platform, env = process.env) {
75
76
  return path.join(env.XDG_DATA_HOME || path.join(home, '.local/share'), 'passioncode-fabric');
76
77
  }
77
78
 
78
- /** Whether a process with this id runs as this user (Fabric hub.ts#pidAlive): EPERM is another user's process. */
79
+ /**
80
+ * true: a process of this OS account runs under pid; false: none does. null (falsy): a process of another account does
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. 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).
84
+ */
79
85
  export function pidAlive(pid) {
80
86
  if (!Number.isInteger(pid) || pid <= 0) return false;
81
87
  try {
82
88
  process.kill(pid, 0);
83
89
  return true;
84
- } catch {
85
- return false;
90
+ } catch (e) {
91
+ return e?.code === 'EPERM' ? null : false;
86
92
  }
87
93
  }
88
94
 
@@ -108,7 +114,8 @@ function readSmall(file) {
108
114
 
109
115
  /**
110
116
  * hub.json read the way an agent must, every call. Answers {origin, mcpUrl, doorTokenFile, pid, startedAt, features} or
111
- * throws HubError (not-running, retryable; not-a-hub). Reads no token. `estate` is {record, applied}: with it, a hubKey
117
+ * throws HubError (not-running, retryable; not-a-hub, retryable when hub.json names another OS account's process, not
118
+ * verified, FAA-304). Reads no token. `estate` is {record, applied}: with it, a hubKey
112
119
  * the document names is checked by hubKeyCertified (DEC-0060); fabric-hub/0.1 names none.
113
120
  */
114
121
  export function locateHub(root = fabricRoot(), { alive = pidAlive, estate, now } = {}) {
@@ -142,7 +149,10 @@ export function locateHub(root = fabricRoot(), { alive = pidAlive, estate, now }
142
149
  sameRoot = false;
143
150
  }
144
151
  if (!sameRoot) throw notHub('names a door token outside the registry root');
145
- if (!alive(doc.pid)) throw new HubError('not-running', `Fabric is not running: hub.json names process ${doc.pid}, which is not running.`, true);
152
+ const live = alive(doc.pid);
153
+ // FAA-304: another account's process under that id, so not this account's Fabric (as FAA-254)
154
+ if (live === null) throw new HubError('not-a-hub', `Fabric's hub.json names process ${doc.pid}, which runs under another OS account: not verified.`, true);
155
+ if (!live) throw new HubError('not-running', `Fabric is not running: hub.json names process ${doc.pid}, which is not running.`, true);
146
156
  const features = Array.isArray(doc.features) ? doc.features.filter((f) => typeof f === 'string') : [];
147
157
  if (estate && doc.hubKey && typeof doc.hubKey === 'object') {
148
158
  const verdict = hubKeyCertified(estate.record, doc.hubKey.kid, doc.hubKey.x, now ?? Date.now() / 1000, estate.applied ?? undefined);
@@ -183,7 +193,7 @@ export function readCredential(file) {
183
193
  * is HubError hub-unavailable (retryable); a listener that is not this OS account's process is HubError foreign-user
184
194
  * (retryable) before the bearer is sent (DEC-0154 HA-2): the connection is checked from the socket table first.
185
195
  */
186
- 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 } = {}) {
187
197
  const m = LOOPBACK_ORIGIN.exec(origin);
188
198
  if (!m) return Promise.reject(new HubError('not-a-hub', 'Only the hub\'s loopback origin is used.'));
189
199
  const payload = method === 'DELETE' ? null : Buffer.from(JSON.stringify(body));
@@ -215,6 +225,9 @@ export function postMcp(origin, body, bearer, { session, timeout = 15000, method
215
225
  res.on('error', () => reject(new HubError('hub-unavailable', 'Fabric\'s hub did not answer (the answer broke off).', true)));
216
226
  });
217
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));
218
231
  req.on('error', (e) => reject(e instanceof ListenerRefused
219
232
  ? listenerRefusal(e)
220
233
  : new HubError('hub-unavailable', `Fabric's hub did not answer (${e.code ?? e.message}).`, true)));
@@ -7,7 +7,7 @@ import crypto from 'node:crypto';
7
7
  import { ServiceError } from './fabric-base.mjs';
8
8
  import { jcs, refusalFromAction, refusalToolResult } from './fabric-delegation.mjs';
9
9
  import { actionDecision, decisionShape, jobCapability } from './fabric-actions.mjs'; // DEC-0121
10
- import { EFFECT_CLASSES, OPERATOR_SOURCE_PROTOCOL, mandateCovers, mandateProblem, withoutSource } from './fabric-mandate.mjs'; // DEC-0125, DEC-0126
10
+ import { EFFECT_CLASSES, OPERATOR_SOURCE_PROTOCOL, isAgentId, mandateCovers, mandateProblem, withoutSource } from './fabric-mandate.mjs'; // DEC-0125, DEC-0126
11
11
  import { isString } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
12
12
  import { isObject } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
13
13
 
@@ -277,6 +277,25 @@ export function jobStep({ mandate, tier, now, self, capability, key, effect, cal
277
277
  /** The project of a call whose project could not be read: never in a mandate's projects (FAA-139). */
278
278
  export const UNREADABLE_PROJECT = '\u0000unreadable';
279
279
 
280
+ /**
281
+ * The caller's agent id when a service's own hook answered something no chain can hold (an id the registry refuses, not
282
+ * a string, or the hook threw): passed as callerAgent it matches no agent, so every mandate reads
283
+ * mandate-not-for-this-call (fail closed, FAA-135). Never an agent id (isAgentId refuses it).
284
+ */
285
+ export const UNREADABLE_AGENT = '\u0000unreadable-agent';
286
+
287
+ /**
288
+ * FAA-135: the caller's agent id as a service's own hook named it from a per-agent credential (contract delegation.md,
289
+ * `surfaces.mcp.auth: own`), read as the mandate chain binds it: pass the answer as actionGate's and jobStep's
290
+ * `callerAgent`. null or undefined (no per-agent credential) is null, and the chain's end alone binds the mandate, as
291
+ * before; an agent id the registry accepts is that id; anything else is UNREADABLE_AGENT. A hook that throws: pass
292
+ * UNREADABLE_AGENT. The Python twin's McpToolServer(identify_agent=) reads it so; never a name the call carries.
293
+ */
294
+ export function identifiedAgent(value) {
295
+ if (value === null || value === undefined) return null;
296
+ return isAgentId(value) ? value : UNREADABLE_AGENT;
297
+ }
298
+
280
299
  /** A spend estimate as a mandate reads it (FAA-139): a finite number stays, anything else is none (a ceiling refuses it). */
281
300
  export function mandateSpend(value) {
282
301
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
@@ -43,6 +43,8 @@ export {
43
43
  mandateSpend,
44
44
  keptStep,
45
45
  UNREADABLE_PROJECT,
46
+ UNREADABLE_AGENT,
47
+ identifiedAgent,
46
48
  actionRefusalToolResult,
47
49
  usageFindings,
48
50
  usageToward,
@@ -271,6 +273,9 @@ export class JobStore {
271
273
  requestInput(jobId, inputRequests, statusMessage, { steps } = {}) {
272
274
  const entries = Object.entries(inputRequests ?? {});
273
275
  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)
276
+ if (steps !== null && steps !== undefined && (typeof steps !== 'object' || Array.isArray(steps))) { // FAA-196 (26)
277
+ throw new InteropError('steps maps an input request\'s key to its approval step: a JSON object, or none.');
278
+ }
274
279
  const kept = Object.create(null); // the value checked is the value kept (seam 34); a key __proto__ stays a key (35)
275
280
  for (const [key, step] of Object.entries(steps ?? {})) {
276
281
  kept[key] = Object.hasOwn(inputRequests, key) && step !== null && step !== undefined ? keptStep(step) : {};
@@ -1,5 +1,15 @@
1
1
  // One copy of a service, locked before any side effect (LC-01, DEC-0057): the instance lock and the supervisor marker.
2
2
  // Part of the Node service kit (FAA-65, layer L3); the twin of fabric_lock.py. Node 20+ (22+ for the Linux lock), no dependency.
3
+ //
4
+ // A lock that moves under its holder (FAA-303). Linux and Windows hold a name keyed by the lock's path (an abstract
5
+ // unix socket, a named pipe), so a folder swapped under a running copy cannot let a second copy in. macOS has no such
6
+ // name: O_EXLOCK (and the Python twin's flock) lock an inode, so after a data import swaps the data folder a second
7
+ // copy locks the new file and starts. There the holder watches its lock instead (InstanceLock#watch, started by
8
+ // holdSingleInstance): every LOCK_WATCH_SECONDS it compares the inode it locked with the file at the path, and once
9
+ // the path names another file it stops with 75 and one sentence naming the path, before writing anything more into a
10
+ // folder that is no longer the one it read. Two copies then overlap for at most one interval. A path-keyed name on
11
+ // macOS was weighed and left: it needs a file in a folder outside the data folder that the system never cleans and
12
+ // every copy can write, which Node alone cannot promise.
3
13
  import crypto from 'node:crypto';
4
14
  import fs from 'node:fs';
5
15
  import net from 'node:net';
@@ -15,6 +25,12 @@ export const LOCK_BACKOFF_FIRST_SECONDS = 0.5;
15
25
 
16
26
  export const LOCK_BACKOFF_MAX_SECONDS = 30;
17
27
 
28
+ /**
29
+ * How often a holder that has no path-keyed name (macOS) compares the inode it locked with the file at the lock's path
30
+ * (FAA-303): one stat and one fstat; two copies overlap for at most this long after a folder swap.
31
+ */
32
+ export const LOCK_WATCH_SECONDS = 2;
33
+
18
34
  const O_EXLOCK = 0x20; // BSD/macOS: open(2) takes flock(LOCK_EX); absent from fs.constants
19
35
 
20
36
  export class AlreadyRunning extends ServiceError {
@@ -34,6 +50,7 @@ export class InstanceLock {
34
50
  this.path = path.join(dataDir, 'service.lock');
35
51
  this.fd = null;
36
52
  this.server = null;
53
+ this.timer = null;
37
54
  }
38
55
 
39
56
  async acquire() {
@@ -124,11 +141,75 @@ export class InstanceLock {
124
141
  return true;
125
142
  }
126
143
 
144
+ /**
145
+ * Re-check, every `intervalSeconds` on an unref'd timer, that the file this copy locked is still the file at the path
146
+ * (FAA-303); the first time the path names another file (a swapped folder's lock, or the one a second copy made),
147
+ * call `onLost(lock)` once and stop watching. The default stops the process with 75 and one sentence
148
+ * (stopLostHolder). A path that is gone or cannot be read now is read as nothing there yet and tried again next time:
149
+ * a second copy has to make a file there to start, and the watch then sees that file. `release` ends the watch. Only
150
+ * a lock held as a file is watched (macOS): elsewhere the descriptor is null and the path-keyed name keeps a second
151
+ * copy out, so this is a no-op, as for a lock not held.
152
+ */
153
+ watch({ onLost, intervalSeconds = LOCK_WATCH_SECONDS } = {}) {
154
+ if (this.fd === null || this.timer !== null) return this;
155
+ const fd = this.fd;
156
+ const react = onLost ?? stopLostHolder;
157
+ this.timer = setInterval(() => {
158
+ let here;
159
+ let held;
160
+ try {
161
+ here = fs.statSync(this.path);
162
+ held = fs.fstatSync(fd);
163
+ } catch {
164
+ return;
165
+ }
166
+ if (here.dev === held.dev && here.ino === held.ino) return;
167
+ clearInterval(this.timer);
168
+ this.timer = null;
169
+ react(this);
170
+ }, intervalSeconds * 1000);
171
+ this.timer.unref();
172
+ return this;
173
+ }
174
+
175
+ /**
176
+ * Let the lock go. Never throws, so a release in a `finally` never replaces the caller's own outcome (FAA-196 (11)):
177
+ * the descriptor and the name's server are forgotten before they are closed, so a retry never closes a descriptor
178
+ * reused since, and a close that fails still closes the server.
179
+ */
127
180
  release() {
128
- if (this.fd !== null) fs.closeSync(this.fd);
129
- if (this.server) this.server.close();
181
+ if (this.timer !== null) clearInterval(this.timer);
182
+ const { fd, server } = this;
183
+ this.timer = null;
130
184
  this.fd = null;
131
185
  this.server = null;
186
+ try {
187
+ if (fd !== null) fs.closeSync(fd);
188
+ } catch { // the descriptor is gone either way; the kernel frees the lock with it
189
+ } finally {
190
+ try {
191
+ server?.close();
192
+ } catch { // nothing to add to the caller's own outcome
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ /** The one sentence a holder whose lock moved under it stops with (FAA-303). */
199
+ export function lostLockMessage(lockPath) {
200
+ return `${lockPath} is no longer the file this copy locked (the data folder moved or was swapped?): stopping, so a copy started on the folder now at that path runs alone.`;
201
+ }
202
+
203
+ /**
204
+ * InstanceLock#watch's default: write lostLockMessage and end the process with 75 at once. Not a graceful stop: a
205
+ * shutdown would write this copy's state into the folder now at the path, which is not the one it read; the kit's
206
+ * journals make an abrupt stop recoverable (LC-03).
207
+ */
208
+ export function stopLostHolder(lock) {
209
+ try {
210
+ fs.writeSync(2, `${lostLockMessage(lock.path)}\n`);
211
+ } finally {
212
+ process.exit(EXIT_ALREADY_RUNNING);
132
213
  }
133
214
  }
134
215
 
@@ -253,10 +334,12 @@ const sleepSeconds = (s) => new Promise((resolve) => setTimeout(resolve, s * 100
253
334
  * supervisor that restarts on exit (launchd's KeepAlive) an immediate exit is a respawn every ThrottleInterval,
254
335
  * forever; so a supervised copy first backs off in-process (0.5 s doubling to 30 s,
255
336
  * LOCK_WAIT_SUPERVISED_SECONDS in all), takes over if the holder leaves, and exits 75 only when
256
- * the wait runs out. `waitSeconds` overrides the choice (0 = never wait).
337
+ * the wait runs out. `waitSeconds` overrides the choice (0 = never wait). Where the lock is held as a file (macOS)
338
+ * the held lock is watched (InstanceLock#watch, FAA-303): a folder swapped under the running copy stops it with 75;
339
+ * `onLost` replaces that reaction.
257
340
  */
258
341
  export async function holdSingleInstance(dataDir, {
259
- waitSeconds, sleep = sleepSeconds, clock = () => performance.now() / 1000, exit = (code) => process.exit(code), env, argv,
342
+ waitSeconds, sleep = sleepSeconds, clock = () => performance.now() / 1000, exit = (code) => process.exit(code), env, argv, onLost,
260
343
  } = {}) {
261
344
  // DEC-0057: markers that disagree exit 75 at once, before the lock, naming them.
262
345
  const marker = supervisorMarker({ ...(env === undefined ? {} : { env }), ...(argv === undefined ? {} : { argv }) });
@@ -270,7 +353,7 @@ export async function holdSingleInstance(dataDir, {
270
353
  let announced = false;
271
354
  for (;;) {
272
355
  try {
273
- return await new InstanceLock(dataDir).acquire();
356
+ return (await new InstanceLock(dataDir).acquire()).watch({ onLost });
274
357
  } catch (error) {
275
358
  if (!(error instanceof AlreadyRunning)) throw error;
276
359
  const remaining = deadline - clock();
@@ -193,6 +193,11 @@ const invalid = (detail) => ({ ok: false, code: MANDATE_INVALID, data: { detail
193
193
  * being where the operator said the words when the mandate carries it (DEC-0126 OA-6). `callerAgent` is
194
194
  * passed only when the callee authenticated the caller's agent id; without one the chain's end alone binds it. A missing
195
195
  * or non-object `call` is read as a call that names nothing (the reference would throw).
196
+ *
197
+ * A call is a plain object of its own data properties (FAA-196 (19)): its members are read as own members only, where
198
+ * the contract's reference reads plain properties, so an inherited or getter `project`, `action` or `spendUsd` is none
199
+ * here and may be covered where the reference refuses it. Build the call as an object literal of the values the work
200
+ * names, as the kit's own readers do (the board's `covers`, actionGate, jobStep).
196
201
  */
197
202
  export function mandateCovers(input) {
198
203
  const field = (key) => member(input, key);
@@ -86,6 +86,26 @@ function checkAttribution(projectId, taskRunId) {
86
86
  }
87
87
  }
88
88
 
89
+ // Why a base URL is not sent the same by both twins, read from the URL as sent (its ends trimmed), or null: a backslash
90
+ // anywhere (new URL reads it as a slash), and after a `//` authority a path holding anything but URL path characters
91
+ // and %XX escapes (Python refused a space or a character outside ASCII where new URL percent-encoded it, and new URL
92
+ // removes a tab or a line break) or a dot segment, escaped or not (new URL collapses it). The Python twin's
93
+ // base_url_problem is the same rule (FAA-187 (18), kit-openrouter-cases.json).
94
+ export function baseUrlProblem(base) {
95
+ const text = String(base).replace(/^[\x00-\x20]+|[\x00-\x20]+$/g, '');
96
+ if (text.includes('\\')) return 'the base URL holds a backslash';
97
+ if (/[\t\n\r]/.test(text)) return 'the base URL holds a tab or a line break'; // new URL removes them and sends the rest
98
+ const rest = text.slice(text.indexOf(':') + 1);
99
+ if (text.indexOf(':') < 0 || !rest.startsWith('//')) return null; // no authority as written: checkUrl reads it
100
+ const after = rest.slice(2);
101
+ const path = after.includes('/') ? after.slice(after.indexOf('/')) : '';
102
+ if (!/^(?:\/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})*)*$/.test(path)) {
103
+ return 'the base URL\'s path holds a character outside URL path characters and %XX escapes';
104
+ }
105
+ if (path.split('/').slice(1).some((seg) => ['.', '..'].includes(seg.replace(/%2e/gi, '.')))) return 'the base URL\'s path holds a dot segment';
106
+ return null;
107
+ }
108
+
89
109
  export class Client {
90
110
  constructor({ ceilingUsd, ledger, prices, keyEnv = 'OPENROUTER_API_KEY', window = 'day', baseUrl = BASE_URL,
91
111
  attribution = null, projectId, unmeasuredCallUsd = null, deadlineS = 120, retries = 1, env = process.env,
@@ -108,13 +128,14 @@ export class Client {
108
128
  if (/[?#]/.test(baseUrl)) {
109
129
  throw new OpenRouterError('the base URL holds no query or fragment', { kind: 'config' });
110
130
  }
131
+ const problem = baseUrlProblem(baseUrl);
132
+ if (problem) throw new OpenRouterError(`${problem} (both kits would not send it alike)`, { kind: 'config' });
111
133
  http.checkUrl(baseUrl);
112
134
  checkAttribution(projectId, undefined);
113
135
  Object.assign(this, { ceilingUsd, ledger, keyEnv, window, attribution, projectId, unmeasuredCallUsd, deadlineS, retries });
114
136
  this.prices = Object.fromEntries(Object.entries(prices ?? {}).map(([k, v]) => [k, { ...v }]));
115
- // The base URL as checkUrl read it: `new URL` removes tabs and line breaks anywhere and trims C0 controls and
116
- // spaces from its ends, so `.../v1/ ` and `.../v1\n/` are composed as `.../v1/chat/completions`, as `new URL`
117
- // composed them in 0.55.0 (0.56.0 product r20, unit r22, seam r22; the Python twin refuses what urllib cannot send).
137
+ // The base URL as checkUrl read it: its ends trimmed of C0 controls and spaces, so `.../v1/ ` is composed as
138
+ // `.../v1/chat/completions` (0.56.0 product r20); a tab or line break inside is refused above since FAA-187 (18).
118
139
  this.baseUrl = baseUrl.replace(/[\t\n\r]/g, '').replace(/^[\x00-\x20]+|[\x00-\x20]+$/g, '').replace(/\/+$/, '');
119
140
  this.env = env;
120
141
  this.transport = transport;