@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
@@ -18,7 +18,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
18
18
 
19
19
  sys.path.insert(0, str(Path(__file__).resolve().parent))
20
20
  from fabric_jsvalue import indexed_objects, js_keys # noqa: E402
21
- from fabric_secrets import JS_SPACE, JWT_SHAPE, SECRET_VALUE, raw_values, secret_argument, secret_url, secret_value # noqa: E402
21
+ from fabric_secrets import JS_SPACE, SECRET_VALUE, has_jwt, raw_values, secret_argument, secret_url, secret_value # noqa: E402
22
22
  from fabric_secrets import url_parts as _whatwg_url_parts # noqa: E402 - public since FAA-250
23
23
  import check_service_channel_agent_schema as cas # noqa: E402
24
24
 
@@ -208,9 +208,9 @@ def read_as_url(read: str) -> bool:
208
208
 
209
209
 
210
210
  def _url_reading(read: str) -> bool:
211
- """secretUrl where read_as_url admits the head, secretArgument otherwise. The kit's secret_url also reads a URL the
212
- WHATWG parser refuses (the safe direction for its own callers); the contract's `new URL` throws there and finds only
213
- what secretArgument finds, so a refused URL is read by secret_argument alone here."""
211
+ """secretUrl where read_as_url admits the head, secretArgument otherwise. A URL the WHATWG parser refuses makes the
212
+ contract's `new URL` throw, which finds only what secretArgument finds, so such a URL is read by secret_argument
213
+ alone: url_parts answers None for it here, as the kit's secret_url reads it as an argument only since FAA-309."""
214
214
  if not read_as_url(read):
215
215
  return secret_argument(read)
216
216
  return secret_argument(read) or (url_parts(read) is not None and secret_url(read))
@@ -222,7 +222,7 @@ def shaped_read(read: str) -> bool:
222
222
 
223
223
  def _key_shaped(text: str) -> bool:
224
224
  read = head(text)
225
- return bool(JWT_SHAPE.search(read) or SECRET_VALUE.search(read))
225
+ return bool(has_jwt(read) or SECRET_VALUE.search(read))
226
226
 
227
227
 
228
228
  def plain_name(name: str) -> bool:
@@ -397,6 +397,8 @@ def url_parts(text: str) -> Optional[Tuple[str, str]]:
397
397
  return None
398
398
  if at and hostport == "":
399
399
  return None
400
+ if host == "" and colon > bracket:
401
+ return None # an empty host ended by `:`, with an empty port or a port: `new URL` throws
400
402
  if host == "" and special and lowered != "file":
401
403
  return None
402
404
  if host and not _host_ok(host, special):
@@ -135,20 +135,31 @@ import fabric_actions as fa # noqa: E402 - beside this file (DEC-0121)
135
135
  import fabric_a2a as fa2 # noqa: E402 - beside this file (DEC-0122)
136
136
 
137
137
 
138
- def other_domain_copy(label: object, domain: str) -> Optional[str]:
139
- """The other launchd domain when it also has job `label` loaded, a second copy (FAA-286 item 6), else None."""
140
- other = ("gui/%d" % getattr(os, "getuid", lambda: 0)()) if domain == "system" else "system"
141
- try:
142
- out = subprocess.run(["launchctl", "print", "%s/%s" % (other, label)], capture_output=True, text=True, timeout=10)
143
- except (OSError, subprocess.SubprocessError):
144
- return None
145
- return other if out.returncode == 0 else None
138
+ def other_domain_copy(label: object, domain: str, user_name: object = None) -> Optional[str]:
139
+ """The other launchd domain when it also has job `label` loaded, a second copy (FAA-286 item 6), else None. For a
140
+ LaunchDaemon (`system`) that is a gui/<uid> domain: first the one of its plist's UserName (`user_name`), where a
141
+ leftover LaunchAgent of a dedicated account runs (FAA-317), then the probe's own."""
142
+ if domain == "system":
143
+ uids = [_sup._account_uid(user_name)] if isinstance(user_name, str) and user_name else []
144
+ uids.append(getattr(os, "getuid", lambda: 0)())
145
+ others = ["gui/%d" % uid for i, uid in enumerate(uids) if uid is not None and uid not in uids[:i]]
146
+ else:
147
+ others = ["system"]
148
+ for other in others:
149
+ try:
150
+ out = subprocess.run(["launchctl", "print", "%s/%s" % (other, label)], capture_output=True, text=True,
151
+ timeout=10)
152
+ except (OSError, subprocess.SubprocessError):
153
+ continue
154
+ if out.returncode == 0:
155
+ return other
156
+ return None
146
157
 
147
158
 
148
159
  def in_launch_daemons(plist_path: Path) -> bool:
149
- """Whether a plist sits in /Library/LaunchDaemons, where launchd loads it at boot as a LaunchDaemon (FAA-278)."""
150
- folder = re.sub(r"^/{2,}", "/", str(Path(os.path.abspath(str(plist_path))).parent))
151
- return folder.lower() == str(_sup.SYSTEM_DAEMONS).lower() # the supervisors' own, which a test may point elsewhere
160
+ """Whether a plist sits in /Library/LaunchDaemons, where launchd loads it at boot as a LaunchDaemon (FAA-278): the
161
+ supervisors' own rule, imported (FAA-317), which a test may point elsewhere through their SYSTEM_DAEMONS."""
162
+ return _sup._in_daemons(plist_path)
152
163
 
153
164
 
154
165
  def plist_problems(plist: dict, label: object, token: object = None, *, system: bool = False,
@@ -0,0 +1,274 @@
1
+ // The serving side and a bounded client of A2A 1.0 (FAA-10, DEC-0122): an Agent Card built from the service's
2
+ // capabilities (agentCard, checked by agentCardFindings before it is served), the JSON-RPC binding (A2ABinding:
3
+ // SendMessage, SendStreamingMessage, GetTask, CancelTask) mapped onto the interop job store (a task is a job, a choice
4
+ // stop is TASK_STATE_INPUT_REQUIRED, the result envelope an artifact), and A2AClient (discover, call, get, cancel)
5
+ // that reaches a peer only on an origin the operator allowed, its credential read from the environment use_secret.py
6
+ // run fills. The interface never takes the descriptor's token (DEC-0122 item 3).
7
+ // Part of the Node service kit (layer L4); the twin of fabric_a2a_server.py. Node 20+, no dependency.
8
+ import crypto from 'node:crypto';
9
+ import { A2A_PROTOCOL_VERSION, A2A_SURFACE_PATH, agentCardFindings } from './fabric-a2a.mjs';
10
+ import { ServiceError } from './fabric-base.mjs';
11
+ import { InteropError, UnknownJob } from './fabric-interop-rules.mjs';
12
+ import { request as httpRequest } from './fabric-http.mjs';
13
+
14
+ export const JSONRPC_BINDING = 'JSONRPC';
15
+ export const PARSE_ERROR = -32700;
16
+ export const INVALID_REQUEST = -32600;
17
+ export const METHOD_NOT_FOUND = -32601;
18
+ export const INVALID_PARAMS = -32602;
19
+ export const INTERNAL_ERROR = -32603;
20
+ export const TASK_NOT_FOUND = -32001;
21
+ export const TASK_NOT_CANCELABLE = -32002;
22
+ export const UNSUPPORTED_OPERATION = -32004;
23
+ export const CONTENT_TYPE_NOT_SUPPORTED = -32005;
24
+ export const VERSION_NOT_SUPPORTED = -32009;
25
+ export const TASK_STATES = Object.freeze({ working: 'TASK_STATE_WORKING', input_required: 'TASK_STATE_INPUT_REQUIRED', completed: 'TASK_STATE_COMPLETED', failed: 'TASK_STATE_FAILED', cancelled: 'TASK_STATE_CANCELED' });
26
+ export const TERMINAL_STATES = Object.freeze(['TASK_STATE_COMPLETED', 'TASK_STATE_FAILED', 'TASK_STATE_CANCELED', 'TASK_STATE_REJECTED']);
27
+ export const MAX_BODY = 1024 * 1024;
28
+ const SKILL_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
29
+ const STOPS = [...TERMINAL_STATES, 'TASK_STATE_INPUT_REQUIRED'];
30
+ const hex = (n) => crypto.randomBytes(n).toString('hex');
31
+
32
+ /** A refused A2A call or card; the message never carries a credential. */
33
+ export class A2AError extends ServiceError {}
34
+
35
+ export function skillFromCapability(capability) {
36
+ const name = capability?.name;
37
+ if (typeof name !== 'string' || !SKILL_ID.test(name)) throw new A2AError("a skill's id is the capability's name: letters, digits, '.', '_' or '-', at most 64");
38
+ const skill = { id: name, name: capability.title || name, description: capability.description || name, tags: [...(capability.tags || [name.split('.')[0]])] };
39
+ for (const key of ['examples', 'inputModes', 'outputModes']) if (capability[key]?.length) skill[key] = [...capability[key]];
40
+ return skill;
41
+ }
42
+
43
+ export function agentCard({ name, description, version, url, capabilities, securitySchemes, streaming = false, extensions, remote = false, descriptorHeader = 'Authorization' }) {
44
+ const match = /^(https?:\/\/[^/?#]+)/.exec(url ?? '');
45
+ const card = {
46
+ name, description, version,
47
+ supportedInterfaces: [{ url, protocolBinding: JSONRPC_BINDING, protocolVersion: A2A_PROTOCOL_VERSION }],
48
+ capabilities: { streaming: Boolean(streaming), pushNotifications: false },
49
+ defaultInputModes: ['text/plain', 'application/json'], defaultOutputModes: ['text/plain', 'application/json'],
50
+ skills: capabilities.map(skillFromCapability),
51
+ };
52
+ if (extensions?.length) card.capabilities.extensions = extensions.map((e) => ({ ...e, required: false }));
53
+ if (securitySchemes) card.securitySchemes = securitySchemes;
54
+ const findings = agentCardFindings(card, { origin: match ? match[1] : undefined, remote, descriptorHeader });
55
+ if (findings.length) throw new A2AError(`the card is not served: ${findings.map((f) => f.message).join('; ')}`);
56
+ return card;
57
+ }
58
+
59
+ export const textOf = (message) => (Array.isArray(message?.parts) ? message.parts : []).filter((p) => typeof p?.text === 'string').map((p) => p.text).join('\n');
60
+ export const dataOf = (message) => (Array.isArray(message?.parts) ? message.parts : []).filter((p) => p && typeof p === 'object' && 'data' in p).map((p) => p.data);
61
+
62
+ export function agentMessage(text, { taskId, contextId, data } = {}) {
63
+ const parts = [{ text }];
64
+ if (data !== undefined) parts.push({ data });
65
+ const message = { role: 'ROLE_AGENT', parts, messageId: `msg-${hex(8)}` };
66
+ if (taskId) message.taskId = taskId;
67
+ if (contextId) message.contextId = contextId;
68
+ return message;
69
+ }
70
+
71
+ /** What a message handler gets: the message, its caller and start(skill), which makes the job that is the task. */
72
+ export class TaskStart {
73
+ constructor(binding, message, caller, contextId) { Object.assign(this, { binding, message, caller, contextId, taskId: null }); }
74
+ start(skill) {
75
+ if (!this.binding.skills.has(skill)) throw new A2AError(`no skill ${skill} on this card`);
76
+ const handle = this.binding.jobs.create(`a2a.${skill}`, { a2a: { message: this.message, contextId: this.contextId, caller: this.caller } });
77
+ this.taskId = handle.job.id;
78
+ return this.taskId;
79
+ }
80
+ }
81
+
82
+ class NotCancelable extends Error {}
83
+ class BadParams extends Error {}
84
+
85
+ const result = (id, value) => ({ jsonrpc: '2.0', id, result: value });
86
+ const error = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
87
+ const event = (id, value) => `data: ${JSON.stringify(result(id, value))}\n\n`;
88
+
89
+ function textAnswer(requests, text) {
90
+ const keys = Object.keys(requests ?? {});
91
+ if (keys.length !== 1 || !text) return null;
92
+ const props = Object.keys(requests[keys[0]]?.params?.requestedSchema?.properties ?? {});
93
+ return props.length === 1 ? { [keys[0]]: { action: 'accept', content: { [props[0]]: text } } } : null;
94
+ }
95
+
96
+ /** The A2A Task an interop job reads as. */
97
+ export function taskOf(job, contextId) {
98
+ const status = { state: TASK_STATES[job.status] ?? 'TASK_STATE_UNSPECIFIED', timestamp: job.updatedAt ?? '' };
99
+ if (job.status === 'input_required') {
100
+ const questions = Object.values(job.inputRequests ?? {}).map((r) => r?.params?.message).filter((q) => typeof q === 'string');
101
+ status.message = agentMessage(job.statusMessage || questions.join('; ') || 'an answer is needed', { taskId: job.id, contextId, data: { inputRequests: job.inputRequests } });
102
+ } else if (job.status === 'failed') {
103
+ status.message = agentMessage(String(job.error?.message || 'the task failed'), { taskId: job.id, contextId, data: { code: job.error?.code } });
104
+ } else if (job.statusMessage) {
105
+ status.message = agentMessage(job.statusMessage, { taskId: job.id, contextId });
106
+ }
107
+ const task = { id: job.id, contextId, status };
108
+ if (job.status === 'completed') task.artifacts = [{ artifactId: 'result', name: 'result', parts: [{ data: job.result }] }];
109
+ return task;
110
+ }
111
+
112
+ /** The JSON-RPC binding over the interop job store; see fabric_a2a_server.A2ABinding. */
113
+ export class A2ABinding {
114
+ constructor(jobs, handler, { skills, authorize, clock = () => Date.now() / 1000 }) {
115
+ Object.assign(this, { jobs, handler, skills: new Set(skills), authorize, clock });
116
+ }
117
+
118
+ task(jobId, caller) {
119
+ const a2a = this.jobs.load(jobId).private?.input?.a2a;
120
+ if (!a2a || typeof a2a !== 'object' || (a2a.caller ?? null) !== (caller ?? null)) throw new UnknownJob(jobId);
121
+ return taskOf(this.jobs.get(jobId), a2a.contextId);
122
+ }
123
+
124
+ handle(body, headers = {}) {
125
+ const lowered = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
126
+ const caller = this.authorize(lowered);
127
+ if (caller === null || caller === undefined) return [401, null];
128
+ if (body instanceof Uint8Array) {
129
+ if (body.length > MAX_BODY) return [413, null];
130
+ try { body = JSON.parse(Buffer.from(body).toString('utf8')); } catch { return [200, error(null, PARSE_ERROR, 'the body is not JSON')]; }
131
+ }
132
+ if (!body || typeof body !== 'object' || Array.isArray(body) || body.jsonrpc !== '2.0' || typeof body.method !== 'string') {
133
+ return [200, error(body && typeof body === 'object' && !Array.isArray(body) ? body.id : null, INVALID_REQUEST, 'not a JSON-RPC 2.0 request')];
134
+ }
135
+ const rid = body.id;
136
+ const version = lowered['a2a-version'];
137
+ if (version !== undefined && String(version).trim() !== A2A_PROTOCOL_VERSION) return [200, error(rid, VERSION_NOT_SUPPORTED, `this agent speaks A2A ${A2A_PROTOCOL_VERSION}`)];
138
+ const params = body.params;
139
+ if (!params || typeof params !== 'object' || Array.isArray(params)) return [200, error(rid, INVALID_PARAMS, 'params is an object')];
140
+ try {
141
+ if (body.method === 'SendStreamingMessage') return [200, error(rid, UNSUPPORTED_OPERATION, 'streaming is served by stream() as text/event-stream')];
142
+ if (body.method === 'SendMessage') return [200, result(rid, this.send(params, caller))];
143
+ if (body.method === 'GetTask') return [200, result(rid, this.task(String(params.id), caller))];
144
+ if (body.method === 'CancelTask') return [200, result(rid, this.cancel(String(params.id), caller))];
145
+ } catch (e) {
146
+ if (e instanceof UnknownJob) return [200, error(rid, TASK_NOT_FOUND, 'no such task')];
147
+ if (e instanceof NotCancelable) return [200, error(rid, TASK_NOT_CANCELABLE, 'the task has ended')];
148
+ if (e instanceof BadParams) return [200, error(rid, INVALID_PARAMS, e.message)];
149
+ if (e instanceof A2AError || e instanceof InteropError) return [200, error(rid, INTERNAL_ERROR, `the agent could not take the message: ${e.message}`)];
150
+ throw e;
151
+ }
152
+ return [200, error(rid, METHOD_NOT_FOUND, `no method ${body.method.slice(0, 64)} here`)];
153
+ }
154
+
155
+ send(params, caller) {
156
+ const message = params.message;
157
+ if (!message || typeof message !== 'object' || !Array.isArray(message.parts) || !message.parts.length) throw new BadParams('params.message is a message with parts');
158
+ if (typeof message.taskId === 'string' && message.taskId) return { task: this.continueTask(message.taskId, message, caller) };
159
+ const contextId = typeof message.contextId === 'string' ? message.contextId : `ctx-${hex(8)}`;
160
+ const start = new TaskStart(this, message, caller, contextId);
161
+ const reply = this.handler(start);
162
+ if (start.taskId !== null) return { task: this.task(start.taskId, caller) };
163
+ if (!reply || typeof reply !== 'object') throw new A2AError('the handler neither replied nor started a task');
164
+ return { message: { ...reply, contextId } };
165
+ }
166
+
167
+ continueTask(taskId, message, caller) {
168
+ const task = this.task(taskId, caller);
169
+ if (task.status.state !== 'TASK_STATE_INPUT_REQUIRED') throw new BadParams('the task waits for no answer');
170
+ const job = this.jobs.get(taskId);
171
+ let responses = dataOf(message).find((d) => d && typeof d === 'object' && d.inputResponses && typeof d.inputResponses === 'object' && !Array.isArray(d.inputResponses))?.inputResponses ?? null;
172
+ if (responses === null) responses = textAnswer(job.inputRequests ?? {}, textOf(message));
173
+ if (!responses || !Object.keys(this.jobs.answer(taskId, responses) ?? {}).length) throw new BadParams("the answer names none of the task's questions");
174
+ return this.task(taskId, caller);
175
+ }
176
+
177
+ cancel(taskId, caller) {
178
+ const task = this.task(taskId, caller);
179
+ if (TERMINAL_STATES.includes(task.status.state)) throw new NotCancelable();
180
+ this.jobs.cancel(taskId);
181
+ return this.task(taskId, caller);
182
+ }
183
+
184
+ async *stream(params, caller, { rid = null, pollS = 1, deadlineS = 300, sleep = (s) => new Promise((r) => setTimeout(r, s * 1000)) } = {}) {
185
+ const first = this.send(params, caller);
186
+ yield event(rid, first);
187
+ if (!first.task) return;
188
+ let task = first.task;
189
+ const end = this.clock() + deadlineS;
190
+ while (!STOPS.includes(task.status.state) && this.clock() < end) {
191
+ await sleep(pollS);
192
+ const now = this.task(task.id, caller);
193
+ if (now.status.state !== task.status.state || JSON.stringify(now.artifacts) !== JSON.stringify(task.artifacts)) {
194
+ yield event(rid, { statusUpdate: { taskId: now.id, contextId: now.contextId, status: now.status, final: STOPS.includes(now.status.state) } });
195
+ }
196
+ task = now;
197
+ }
198
+ }
199
+ }
200
+
201
+ /** scheme://host[:port] of a URL, lower-cased, a default port dropped. */
202
+ export function originOf(url) {
203
+ const match = /^(https?):\/\/([^/?#]+)/i.exec(url ?? '');
204
+ if (!match || match[2].includes('@')) throw new A2AError("a peer's address is an http(s) origin with no userinfo");
205
+ const scheme = match[1].toLowerCase();
206
+ let host = match[2].toLowerCase();
207
+ const port = host.split(':').pop();
208
+ if (((scheme === 'https' && port === '443') || (scheme === 'http' && port === '80')) && host.includes(':') && !host.endsWith(']')) host = host.slice(0, host.lastIndexOf(':'));
209
+ return `${scheme}://${host}`;
210
+ }
211
+
212
+ /** A bounded A2A 1.0 client; see fabric_a2a_server.A2AClient. */
213
+ export class A2AClient {
214
+ constructor(allowOrigins, { credentialEnv, header = 'Authorization', scheme = 'Bearer', deadlineS = 30, maxBytes = MAX_BODY, transport, insecure = false } = {}) {
215
+ this.allowed = new Set(allowOrigins.map(originOf));
216
+ if (!this.allowed.size) throw new A2AError("a client needs the operator's list of peer origins");
217
+ Object.assign(this, { credentialEnv, header, scheme, deadlineS, maxBytes, transport, insecure, ids: 0 });
218
+ }
219
+
220
+ check(url) { if (!this.allowed.has(originOf(url))) throw new A2AError(`${originOf(url)} is not on the operator's list of A2A peers`); }
221
+
222
+ headers() {
223
+ const headers = { 'A2A-Version': A2A_PROTOCOL_VERSION, Accept: 'application/json' };
224
+ if (this.credentialEnv) {
225
+ const value = process.env[this.credentialEnv];
226
+ if (!value) throw new A2AError(`${this.credentialEnv} is empty: run the caller under use_secret.py run, which places the key`);
227
+ headers[this.header] = this.scheme ? `${this.scheme} ${value}` : value;
228
+ }
229
+ return headers;
230
+ }
231
+
232
+ options(extra) { return { deadlineS: this.deadlineS, maxBytes: this.maxBytes, insecure: this.insecure, ...(this.transport ? { transport: this.transport } : {}), ...extra }; }
233
+
234
+ async discover(origin) {
235
+ const url = originOf(origin) + A2A_SURFACE_PATH;
236
+ this.check(url);
237
+ const response = await httpRequest('GET', url, this.options({ headers: { Accept: 'application/json' } }));
238
+ if (response.status !== 200) throw new A2AError(`the peer answered HTTP ${response.status} for its card`);
239
+ let card;
240
+ try { card = response.json(); } catch { throw new A2AError("the peer's card is not JSON"); }
241
+ if (!card || typeof card !== 'object' || Array.isArray(card)) throw new A2AError("the peer's card is not a JSON object");
242
+ for (const i of card.supportedInterfaces ?? []) {
243
+ if (i && i.protocolBinding === JSONRPC_BINDING && i.protocolVersion === A2A_PROTOCOL_VERSION && typeof i.url === 'string') {
244
+ this.check(i.url);
245
+ return { card, url: i.url };
246
+ }
247
+ }
248
+ throw new A2AError(`the peer's card names no A2A ${A2A_PROTOCOL_VERSION} JSON-RPC interface`);
249
+ }
250
+
251
+ async rpc(url, method, params) {
252
+ this.check(url);
253
+ this.ids += 1;
254
+ const response = await httpRequest('POST', url, this.options({ headers: this.headers(), body: { jsonrpc: '2.0', id: this.ids, method, params }, retries: 0 }));
255
+ if (response.status !== 200) throw new A2AError(`the peer answered HTTP ${response.status} to ${method}`);
256
+ let answer = null;
257
+ try { answer = response.json(); } catch { /* answered below */ }
258
+ if (!answer || typeof answer !== 'object' || Array.isArray(answer)) throw new A2AError(`the peer's answer to ${method} is not a JSON-RPC response`);
259
+ if ('error' in answer) throw new A2AError(`the peer refused ${method}: ${answer.error?.code} ${String(answer.error?.message).slice(0, 200)}`);
260
+ return answer.result;
261
+ }
262
+
263
+ call(url, text, { data, taskId, contextId } = {}) {
264
+ const parts = [{ text }];
265
+ if (data !== undefined) parts.push({ data });
266
+ const message = { role: 'ROLE_USER', parts, messageId: `msg-${hex(8)}` };
267
+ if (taskId) message.taskId = taskId;
268
+ if (contextId) message.contextId = contextId;
269
+ return this.rpc(url, 'SendMessage', { message });
270
+ }
271
+
272
+ get(url, taskId) { return this.rpc(url, 'GetTask', { id: taskId }); }
273
+ cancel(url, taskId) { return this.rpc(url, 'CancelTask', { id: taskId }); }
274
+ }
@@ -2,7 +2,7 @@
2
2
  // schema reads it (a2aSurfaceFindings, a2aView; a2aSurface builds one), and what a public card must not carry
3
3
  // (agentCardFindings): no member about the running process, no secret, no securitySchemes entry naming the descriptor's
4
4
  // token header, every Fabric extension required: false (or absent, which A2A reads as false), and interface URLs where the placement puts them. Serving a
5
- // card, the A2A binding and a client are not here (FAA-10).
5
+ // card, the A2A binding and a client are in fabric-a2a-server.mjs (FAA-10).
6
6
  // Part of the Node service kit (layer L3); the twin of fabric_a2a.py. Node 20+, no dependency.
7
7
  import { ServiceError } from './fabric-base.mjs';
8
8
  import { isObject } from './fabric-jsvalue.mjs';
@@ -7,7 +7,7 @@ import { PROTOCOL, ServiceError, nowIso } from './fabric-base.mjs';
7
7
  import { tokenMatches, windowsTokenProblem } from './fabric-platform.mjs';
8
8
  import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
9
9
  import { isPlainObject } from './fabric-usage.mjs';
10
- import { descriptorPath, namesRepository, sourceRefusal, validateDescriptor, writeDescriptor } from './fabric-descriptor.mjs';
10
+ import { descriptorPath, namesRepository, sourceRefusal, validateDescriptor, warnNoSource, writeDescriptor } from './fabric-descriptor.mjs';
11
11
 
12
12
  export const SESSION_COOKIE = 'fabric_session';
13
13
 
@@ -286,9 +286,11 @@ export function registerRemote({ id, instance = 'default', name, summary, origin
286
286
  if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
287
287
  const refusal = sourceRefusal(d, dir); // before the token is written: a refused install leaves nothing (FAA-198)
288
288
  if (refusal) throw new ServiceError(refusal);
289
+ const warned = !namesRepository(d);
290
+ if (warned) warnNoSource(d); // before the token: a warning that throws leaves both files as they were (FAA-200 (1))
289
291
  ensurePrivateDir(path.dirname(tokenFile));
290
292
  atomicWrite(tokenFile, String(token).trim(), 0o600);
291
- return writeDescriptor(d, dir);
293
+ return writeDescriptor(d, dir, { warned });
292
294
  }
293
295
 
294
296
  export function cookieValue(header, name = SESSION_COOKIE) {
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { Backoff, HubError, MCP_PROTOCOL_VERSION, normalProject, postMcp, toolAnswer } from './fabric-hub.mjs';
6
6
  import { COMMS_PROTOCOL, RESPOND_TOOLS } from './fabric-board-shapes.mjs';
7
7
  import { BoardSession, Responder } from './fabric-board-session.mjs';
8
+ import { LEVELS } from './fabric-events.mjs'; // the levels the event log takes (FAA-187 (23))
8
9
 
9
10
  const FIXTURE_PROJECT = '3f2a6b1c-0d4e-4f5a-8b6c-7d8e9f0a1b2c';
10
11
 
@@ -28,7 +29,11 @@ function fixtureAnswers(variant) {
28
29
  };
29
30
  }
30
31
 
31
- /** One scripted conversation per path through this kit, with no Fabric; answers the problems found (empty: it passes). */
32
+ /**
33
+ * One scripted conversation per path through this kit, with no Fabric; answers the problems found (empty: it passes).
34
+ * Its notices are recorded: one at a level the event log refuses, one without its subject, or a fenced attempt with no
35
+ * warning fails it (FAA-187 (23)).
36
+ */
32
37
  export async function selfCheck() {
33
38
  const os = await import('node:os');
34
39
  const problems = [];
@@ -45,6 +50,8 @@ export async function selfCheck() {
45
50
  const answers = fixtureAnswers(variant);
46
51
  const calls = [];
47
52
  const handled = [];
53
+ const notices = []; // recorded as JsonlEventLog.append takes them: (kind, level, text, {notify, subject, ...}) (FAA-187 (23))
54
+ const emit = (kind, level, text, options = {}) => { notices.push([kind, level, options?.subject]); };
48
55
  const post = async (origin, body, bearer, { method = 'POST' } = {}) => {
49
56
  if (method === 'DELETE') return { status: 200, headers: {}, body: null };
50
57
  if (body.method === 'initialize') return { status: 200, headers: { 'mcp-session-id': 'fixture-session' }, body: { jsonrpc: '2.0', id: body.id, result: { protocolVersion: MCP_PROTOCOL_VERSION } } };
@@ -58,9 +65,14 @@ export async function selfCheck() {
58
65
  const { HubClient } = await import('./fabric-hub.mjs');
59
66
  const responder = new Responder({ hub: new HubClient(root, { post }), projectId: FIXTURE_PROJECT, capabilities: ['fixture.render'], credentialFile: cred,
60
67
  checkpointFile: path.join(tmp, 'checkpoint.json'), handler: (request) => { handled.push(request.body); return { state: 'completed', summary: 'Rendered.' }; },
61
- admit: () => null, now: () => 0, rand: () => 0.5, sessionFactory: (grant) => new BoardSession(grant, { post }) });
68
+ admit: () => null, now: () => 0, rand: () => 0.5, sessionFactory: (grant) => new BoardSession(grant, { post }), emit });
62
69
  await responder.step();
63
70
  const tools = calls.map(([t]) => t);
71
+ for (const [kind, level, subject] of notices) {
72
+ if (!LEVELS.includes(level)) problems.push(`${variant}: notice ${kind} at level '${level}', which the event log refuses`);
73
+ if (!(subject && typeof subject === 'object' && subject.type && subject.id)) problems.push(`${variant}: notice ${kind} without its subject`);
74
+ }
75
+ if (variant === 'fenced' && !notices.some(([, level]) => level === 'warning')) problems.push('fenced: no warning notice reached the service\'s emit');
64
76
  if (tools.some((t) => !RESPOND_TOOLS.has(t) && t !== 'fabric.board.join')) problems.push(`${variant}: a tool outside the respond scope was sent`);
65
77
  if (variant === 'whole') {
66
78
  if (tools.join(',') !== 'fabric.board.join,com.status,com.enroll,com.claim,com.get,com.accept,com.complete') problems.push(`whole: the calls were ${tools.join(', ')}`);
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import { HubError, MCP_PROTOCOL_VERSION, postMcp, toolAnswer } from './fabric-hub.mjs';
7
7
  import { atomicWrite, ensurePrivateDir } from './fabric-service.mjs';
8
8
  import { BoardError, RESPOND_TOOLS, isObj, refusalProblems } from './fabric-board-shapes.mjs';
9
- import { UNREADABLE_PROJECT, mandateSpend } from './fabric-interop-rules.mjs'; // FAA-139
9
+ import { UNREADABLE_AGENT, UNREADABLE_PROJECT, identifiedAgent, mandateSpend } from './fabric-interop-rules.mjs'; // FAA-139, FAA-135
10
10
  import { isAgentId, mandateCovers } from './fabric-mandate.mjs';
11
11
  import { formatRfc3339 } from './fabric-time.mjs';
12
12
 
@@ -26,9 +26,9 @@ export class BoardSession {
26
26
  return run;
27
27
  }
28
28
 
29
- async #send(body, method = 'POST', timeout = null) {
29
+ async #send(body, method = 'POST', timeout = null, deadline = 0) {
30
30
  try {
31
- return await this.post(this.grant.origin, body, this.grant.bearer(), { session: this.mcpSession, timeout: timeout > 0 ? timeout : this.timeout, method, protocolVersion: this.mcpSession ? this.protocolVersion : undefined });
31
+ return await this.post(this.grant.origin, body, this.grant.bearer(), { session: this.mcpSession, timeout: timeout > 0 ? timeout : this.timeout, method, protocolVersion: this.mcpSession ? this.protocolVersion : undefined, ...(deadline > 0 ? { deadline } : {}) });
32
32
  } catch (e) {
33
33
  // FAA-252: another OS account's process holds the port; nothing was sent
34
34
  if (e instanceof HubError && e.code === 'foreign-user') throw new BoardError('foreign-user', 'The hub\'s port is held by another OS account\'s process; nothing was sent.', true);
@@ -71,13 +71,13 @@ export class BoardSession {
71
71
  });
72
72
  }
73
73
 
74
- /** Sends the DELETE (waiting `timeout` ms at most); true when Fabric answered it with a 2xx, so the slots are free now (FAA-163). A 404 (already gone) is false: a move made before it ended is read at the next com.status, so the slot records stay. */
74
+ /** Sends the DELETE (waiting `timeout` ms at most for the whole of it, FAA-187 (12)); true when Fabric answered it with a 2xx, so the slots are free now (FAA-163). A 404 (already gone) is false: a move made before it ended is read at the next com.status, so the slot records stay. */
75
75
  close(timeout = null) {
76
76
  return this.#serial(async () => {
77
77
  if (!this.mcpSession) return false;
78
78
  let status = 0;
79
79
  try {
80
- const answer = await this.#send({}, 'DELETE', timeout);
80
+ const answer = await this.#send({}, 'DELETE', timeout, timeout > 0 ? timeout : 0); // the whole DELETE (FAA-187 (12))
81
81
  status = Number.isInteger(answer?.status) ? answer.status : 0; // an answer it cannot read is no release, as in Python
82
82
  } catch {
83
83
  // best effort: the lease lapses in 60 s anyway
@@ -92,7 +92,7 @@ export class BoardSession {
92
92
  export class Checkpoint {
93
93
  constructor(file) {
94
94
  this.file = file;
95
- this.slots = {}; // the responder's own record of each slot it held: {project, generation, moved?} (FAA-155)
95
+ this.slots = Object.create(null); // the responder's own record of each slot it held: {project, generation, moved?} (FAA-155); no prototype, so a capability named `constructor` reads as any other (FAA-196 (24))
96
96
  this.entries = this.#load();
97
97
  }
98
98
  #load() {
@@ -111,7 +111,7 @@ export class Checkpoint {
111
111
  }
112
112
  if (!isObj(doc) || doc.version !== 1 || !isObj(doc.requests)) throw new BoardError('checkpoint', `The board checkpoint ${this.file} is not one this kit writes.`);
113
113
  // a record that is not an object is ignored, as a file without one is
114
- this.slots = isObj(doc.slots) ? Object.fromEntries(Object.entries(doc.slots).filter(([, v]) => isObj(v))) : {};
114
+ this.slots = Object.assign(Object.create(null), isObj(doc.slots) ? Object.fromEntries(Object.entries(doc.slots).filter(([, v]) => isObj(v))) : {});
115
115
  return doc.requests;
116
116
  }
117
117
  #write(entries, slots = this.slots) {
@@ -137,7 +137,8 @@ export class Checkpoint {
137
137
  }
138
138
  /** Writes the record of a slot this responder held (null forgets it); nothing is written when it is unchanged. */
139
139
  slot(capability, record) {
140
- const { [capability]: _old, ...slots } = this.slots;
140
+ const slots = Object.assign(Object.create(null), this.slots);
141
+ delete slots[capability];
141
142
  if (record !== null) slots[capability] = JSON.parse(JSON.stringify(record));
142
143
  if (JSON.stringify(sortDeep(slots)) === JSON.stringify(sortDeep(this.slots))) return;
143
144
  this.#write(this.entries, slots);
@@ -239,7 +240,7 @@ export function readMandate(request, mandateFrom, agentId) {
239
240
  try {
240
241
  value = JSON.parse(block);
241
242
  } catch {
242
- if (block.includes('mandate/1')) return { mandate: null, unread: 'mandate-unreadable' };
243
+ if (/mandate(?:\/|\\\/|\\u002[fF])1/.test(block)) return { mandate: null, unread: 'mandate-unreadable' }; // the kind as written, its slash JSON-escaped too (FAA-196 (13))
243
244
  continue;
244
245
  }
245
246
  if (isObj(value) && value.kind === 'mandate/1') {
@@ -263,7 +264,7 @@ export function readMandate(request, mandateFrom, agentId) {
263
264
  * absent (an inherited, non-enumerable, getter or Proxy member included); the rest of the call is never touched. A
264
265
  * getter whose value changes is answered on its one read.
265
266
  */
266
- export function covers(mandate, call, agentId, wall, caller = null) {
267
+ export function covers(mandate, call, agentId, wall, caller = null, callerAgent = null) {
267
268
  if (mandate == null || agentId == null) return null;
268
269
  if (call !== null && typeof call === 'object' && !Array.isArray(call)) {
269
270
  const read = {};
@@ -284,10 +285,52 @@ export function covers(mandate, call, agentId, wall, caller = null) {
284
285
  } catch { // finite but outside the dates a clock can name
285
286
  now = '';
286
287
  }
287
- const answer = mandateCovers({ mandate, tier: 'T1', now, self: agentId, call });
288
+ const inputs = { mandate, tier: 'T1', now, self: agentId, call };
289
+ if (callerAgent !== null && callerAgent !== undefined) inputs.callerAgent = callerAgent; // FAA-135
290
+ const answer = mandateCovers(inputs);
288
291
  return answer.recordAs ? { ok: true, recordAs: { ...answer.recordAs, caller } } : answer;
289
292
  }
290
293
 
294
+ /**
295
+ * A responder's `links` (FAA-187 (21)): the page on the service that resolves each notify kind, such as
296
+ * `{'board.slot.moved': '/#board'}`, passed to a notice that notifies as its `link`. A path on the service, as makeEvent
297
+ * takes one; null is none. A service that gives links has an emit that takes `link`.
298
+ */
299
+ export function noticeLinks(links) {
300
+ if (links === null || links === undefined) return Object.create(null);
301
+ if (!isObj(links) || !Object.entries(links).every(([, v]) => typeof v === 'string' && v.startsWith('/') && !v.startsWith('//'))) {
302
+ throw new BoardError('invalid_arguments', 'links maps an event kind to a path on this service, such as /#board.');
303
+ }
304
+ return Object.assign(Object.create(null), links);
305
+ }
306
+
307
+ /**
308
+ * Why a message read back with com.get is not the request a claim took (the first path refuses it, a resume reads no
309
+ * mandate from it, FAA-196 (1)): `digest-mismatch` (another digest than the claim's), `not-addressed` (not this
310
+ * request, capability and Project), else null.
311
+ */
312
+ export function readBackProblem(message, entry, project) {
313
+ if (!isObj(message) || message.digest !== entry.digest) return 'digest-mismatch';
314
+ const req = message.request;
315
+ if (!isObj(req) || req.id !== entry.request || req.capability !== entry.capability || req.target !== project) return 'not-addressed';
316
+ return null;
317
+ }
318
+
319
+ /**
320
+ * FAA-135: the agent id the service's identifyAgent(sender) names for a request's sender (the board's verified
321
+ * `{project, principal}`), read as identifiedAgent reads a hook's answer: null or undefined is none, an agent id is that
322
+ * id, anything else, or a hook that throws, is UNREADABLE_AGENT, which no chain holds. Asked only when a mandate
323
+ * travelled (`mandate` not null).
324
+ */
325
+ export function senderAgent(identifyAgent, sender, mandate = true) {
326
+ if (identifyAgent == null || mandate == null) return null;
327
+ try {
328
+ return identifiedAgent(identifyAgent(sender));
329
+ } catch {
330
+ return UNREADABLE_AGENT; // the service's own hook: fail closed
331
+ }
332
+ }
333
+
291
334
  /** The responder's opt-in, checked: answers `mandateFrom` as the responder keeps it (a Set or the string). */
292
335
  export function mandateOptions(agentId, mandateFrom) {
293
336
  if (agentId != null && !isAgentId(agentId)) throw new BoardError('invalid_arguments', 'agentId must be the service\'s registry agent id.');