@passioncode-ai/fabric-agent-adapter 0.82.0 → 0.84.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 (165) hide show
  1. package/CHANGELOG.md +169 -3
  2. package/README.md +7 -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_identifiers.py +1 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +38 -12
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +21 -148
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -8
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +4 -3
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +2 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +3 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +14 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +28 -12
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +13 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +11 -7
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -12
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +275 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +2 -1
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +1 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +5 -2
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +1 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +1 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +15 -2
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +55 -11
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +47 -31
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +1 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +25 -7
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +27 -5
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +1 -0
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +1 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +4 -2
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +19 -12
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate-rules.mjs +1 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +1 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +7 -1
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +22 -7
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +4 -5
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +7 -2
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-identifiers.mjs +1 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +21 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +6 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +1 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +1 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +16 -2
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +1 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +6 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +25 -3
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +456 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package-schema.mjs +1 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +18 -2
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-page-links.mjs +1 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +19 -5
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +56 -19
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +1 -0
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -0
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +16 -2
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -0
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +181 -0
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +20 -15
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +469 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet.mjs +1 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +1 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +1 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +3 -1
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +405 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +1 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +7 -2
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +1 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +1 -0
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +17 -3
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +98 -9
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +37 -48
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +1 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +25 -8
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +1 -0
  106. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +28 -6
  107. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +1 -0
  108. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +1 -0
  109. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +19 -13
  110. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +24 -14
  111. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +1 -0
  112. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate_rules.py +1 -0
  113. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +4 -1
  114. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +32 -7
  115. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +19 -16
  116. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +10 -5
  117. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py +1 -0
  118. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +56 -96
  119. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +99 -1
  120. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +1 -0
  121. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +1 -0
  122. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +16 -6
  123. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +1 -0
  124. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +1 -0
  125. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +44 -1
  126. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +648 -0
  127. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +17 -4
  128. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package_schema.py +1 -0
  129. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_page_links.py +1 -0
  130. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +21 -5
  131. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +108 -108
  132. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +1 -0
  133. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -0
  134. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -0
  135. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +38 -12
  136. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +1 -0
  137. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +1 -0
  138. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +391 -0
  139. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +19 -16
  140. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet.py +1 -0
  141. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +493 -0
  142. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +1 -0
  143. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +1 -0
  144. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +632 -104
  145. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
  146. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +17 -7
  147. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
  148. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +6 -5
  149. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
  150. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
  151. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
  152. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
  153. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
  154. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +4 -3
  155. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +182 -0
  156. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  157. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  158. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
  159. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +14 -9
  160. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +7 -4
  161. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
  162. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
  163. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +16 -5
  164. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +79 -8
  165. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
2
3
  """Reference kit for the fabric-interop/0.1 extension: how agents are called.
3
4
 
4
5
  Standard library only, Python 3.9+. It sits beside fabric_service.py and uses it for
@@ -17,7 +18,6 @@ Normative source: fabric-agent-contract docs/specification/interop.md (DEC-0016,
17
18
 
18
19
  from __future__ import annotations
19
20
 
20
- import base64
21
21
  import hashlib
22
22
  import json
23
23
  import math
@@ -98,6 +98,14 @@ from fabric_interop_rules import ( # noqa: E402,F401 - the rule layer (FAA-92)
98
98
  pause_message,
99
99
  intent_digest,
100
100
  repeated_key_decision,
101
+ NAMED_METHODS,
102
+ UNREADABLE_AGENT,
103
+ identified_agent,
104
+ HEADER_MISMATCH,
105
+ encode_header_value,
106
+ decode_header_value,
107
+ mcp_header_problem,
108
+ mcp_request_headers,
101
109
  )
102
110
 
103
111
  # --- C3.1 capabilities as tools --------------------------------------------------
@@ -399,6 +407,8 @@ class JobStore:
399
407
  A step is kept as the gate reads it (kept_step), so a job's start and its answer agree."""
400
408
  if not isinstance(input_requests, dict) or not input_requests: # a JSON object of requests, first (readings 38, 39)
401
409
  raise InteropError("input_required needs a JSON object of at least one input request.")
410
+ if steps is not None and not isinstance(steps, dict): # a JSON object or none, in both kits (FAA-196 (26))
411
+ raise InteropError("steps maps an input request's key to its approval step: a JSON object, or none.")
402
412
  kept: Dict[str, Dict[str, Any]] = {}
403
413
  for key, step in (steps or {}).items(): # the value checked is the value kept (seam reading 34)
404
414
  kept[key] = kept_step(step) if key in input_requests and isinstance(step, dict) else {}
@@ -493,7 +503,8 @@ class CallContext:
493
503
  def __init__(self, server: "McpToolServer", tool: str, arguments: Any, traceparent: str,
494
504
  delegation: Optional[Dict[str, Any]] = None, *, tier: Optional[str] = None, caller: Optional[str] = None,
495
505
  meta: Optional[Dict[str, Any]] = None, record: Any = None, decision: Optional[Dict[str, Any]] = None,
496
- input_records: Optional[Dict[str, Any]] = None, mandate: Optional[Dict[str, Any]] = None):
506
+ input_records: Optional[Dict[str, Any]] = None, mandate: Optional[Dict[str, Any]] = None,
507
+ caller_agent: Optional[str] = None):
497
508
  self.server = server
498
509
  self.tool = tool
499
510
  self.arguments = arguments
@@ -503,12 +514,19 @@ class CallContext:
503
514
  self.tier, self.caller, self.meta = tier, caller, {} if meta is None else meta
504
515
  self.record, self.decision, self.input_records = record, decision, input_records
505
516
  self.mandate = mandate
517
+ # FAA-135: the caller's agent id the service authenticated (the delegation's caller, else identify_agent's).
518
+ delegated = (delegation or {}).get("caller")
519
+ self.caller_agent = caller_agent if caller_agent is not None else (delegated if isinstance(delegated, str) and delegated else None)
506
520
 
507
521
  def start_job(self, poll_interval_ms: Optional[int] = None) -> Dict[str, Any]:
508
522
  if self.server.jobs is None:
509
523
  raise InteropError("This server has no job store.")
510
524
  handle = self.server.jobs.create(self.tool, self.arguments, self.traceparent, poll_interval_ms, self.delegation)
511
- self.server.job_started(handle["job"]["id"], self)
525
+ try:
526
+ self.server.job_started(handle["job"]["id"], self)
527
+ except InteropError as exc: # a refused start: the job it created ends with it, never left working (FAA-196 (28))
528
+ self.server.jobs.fail(handle["job"]["id"], "tool-error", str(exc)[:_MAX_ERROR_MESSAGE] or "The job's start was refused.")
529
+ raise
512
530
  return handle
513
531
 
514
532
  def approval_step(self, job_id: str, key: str, request: Dict[str, Any], *, effect: str, spend_usd: Any = None,
@@ -527,7 +545,7 @@ class CallContext:
527
545
  stored = self.server.jobs.private(job_id).get("capability")
528
546
  capability = stored if isinstance(stored, str) else self.tool
529
547
  if self.server.agent_id is not None:
530
- caller = (self.delegation or {}).get("caller")
548
+ caller = self.caller_agent
531
549
  carried = self.meta.get("mandate")
532
550
  if carried is None and "mandate" in self.meta:
533
551
  carried = {} # a null mandate is present and unreadable, as the answer reads it; never "none" (FAA-178)
@@ -550,84 +568,6 @@ JOB_REQUEST_SCHEMA = {"type": "object", "required": ["id"], "additionalPropertie
550
568
  "inputResponses": {"type": "object"}}}
551
569
 
552
570
 
553
- NAMED_METHODS = {"tools/call": "name", "prompts/get": "name", "resources/read": "uri"}
554
- HEADER_MISMATCH = -32020 # the code the official 2026-07-28 server SDK answers a header/body mismatch with
555
-
556
-
557
- _SENTINEL = ("=?base64?", "?=")
558
-
559
-
560
- def encode_header_value(value: str) -> str:
561
- """A body value as an HTTP header value, by MCP 2026-07-28 Streamable HTTP "Value Encoding": as
562
- it is when it is printable ASCII without leading or trailing whitespace, otherwise — and for a
563
- plain value that itself looks like the sentinel — `=?base64?<base64 of its UTF-8>?=`."""
564
- plain = (all(0x20 <= ord(c) <= 0x7E for c in value) and value == value.strip(" ")
565
- and not (value.startswith(_SENTINEL[0]) and value.endswith(_SENTINEL[1])))
566
- if plain:
567
- return value
568
- return _SENTINEL[0] + base64.b64encode(value.encode("utf-8")).decode("ascii") + _SENTINEL[1]
569
-
570
-
571
- def decode_header_value(value: str) -> Optional[str]:
572
- """The body value a header carries, decoding the Base64 sentinel; None for a header value with
573
- invalid characters or a sentinel that is not valid Base64 of UTF-8 (a server rejects both)."""
574
- if value.startswith(_SENTINEL[0]) and value.endswith(_SENTINEL[1]) and len(value) >= len(_SENTINEL[0]) + len(_SENTINEL[1]):
575
- try:
576
- return base64.b64decode(value[len(_SENTINEL[0]):-len(_SENTINEL[1])], validate=True).decode("utf-8")
577
- except ValueError: # binascii.Error and UnicodeDecodeError are both ValueErrors
578
- return None
579
- if any(not (0x20 <= ord(c) <= 0x7E or c == "\t") for c in value):
580
- return None
581
- return value
582
-
583
-
584
- def mcp_header_problem(message: Any, headers: Any) -> Optional[str]:
585
- """MCP 2026-07-28 Streamable HTTP mirrors the body in `Mcp-Method` (every request and
586
- notification) and `Mcp-Name` (`params.name` or `params.uri` of tools/call, prompts/get,
587
- resources/read). A server MUST reject a mismatch; a request that declares 2026-07-28 must
588
- carry them. Earlier revisions may omit them, but what they send must still agree.
589
- `headers` is any case-insensitive mapping with `.get` (http.server's message, a dict)."""
590
- if not isinstance(message, dict) or headers is None:
591
- return None
592
- def header(name: str) -> Optional[str]:
593
- value = headers.get(name) if hasattr(headers, "get") else None
594
- if value is None and isinstance(headers, dict):
595
- value = next((v for k, v in headers.items() if k.lower() == name.lower()), None)
596
- if isinstance(value, (list, tuple)):
597
- value = value[0] if value else None
598
- return None if value is None else str(value)
599
- declared = header("MCP-Protocol-Version") == MCP_REVISION
600
- method = message.get("method")
601
- sent = header("Mcp-Method")
602
- if sent is None and declared:
603
- return "the body names method %s but the required Mcp-Method header is absent" % method
604
- if sent is not None and sent != method:
605
- return "Mcp-Method %s disagrees with the body's method %s" % (sent, method)
606
- field = NAMED_METHODS.get(str(method))
607
- if field:
608
- params = message.get("params")
609
- value = params.get(field) if isinstance(params, dict) else None
610
- named = header("Mcp-Name")
611
- if named is None and declared:
612
- return "the body carries params.%s but the required Mcp-Name header is absent" % field
613
- if named is not None:
614
- decoded = decode_header_value(named)
615
- if decoded is None:
616
- return "Mcp-Name is malformed: invalid characters or a broken =?base64?…?= value"
617
- if decoded != str(value):
618
- return "Mcp-Name disagrees with the body's params.%s" % field
619
- return None
620
-
621
-
622
- def mcp_request_headers(method: str, params: Dict[str, Any]) -> Dict[str, str]:
623
- """The standard headers a 2026-07-28 client sends with one request."""
624
- out = {"MCP-Protocol-Version": MCP_REVISION, "Mcp-Method": method}
625
- field = NAMED_METHODS.get(method)
626
- if field and params.get(field) is not None:
627
- out["Mcp-Name"] = encode_header_value(str(params[field]))
628
- return out
629
-
630
-
631
571
  _UNREADABLE = object() # what a service's spend or project function answers when it raised
632
572
  _UNREADABLE_PROJECT = UNREADABLE_PROJECT # never a member of a mandate's projects, so a scoped mandate refuses
633
573
 
@@ -649,7 +589,8 @@ class McpToolServer:
649
589
  approve: Optional[Callable[[str, Dict[str, Any], Any, str], Optional[Dict[str, Any]]]] = None,
650
590
  elicited: Optional[Callable[[str, Dict[str, Any], Optional[str]], bool]] = None,
651
591
  input_actions: bool = False, clock: Callable[[], float] = time.time,
652
- on_fault: Optional[Callable[[str, str], None]] = None, agent_id: Optional[str] = None):
592
+ on_fault: Optional[Callable[[str, str], None]] = None, agent_id: Optional[str] = None,
593
+ identify_agent: Optional[Callable[[Any, str], Optional[str]]] = None):
653
594
  # authorize(tool, headers, tier=) runs before every handler (fabric_delegation.delegation_gate, DEC-0039):
654
595
  # a refusal is answered in the DL-19 shape and the handler never runs. The built-in job tools are
655
596
  # gated by the job's own capability, and answered only for the principal and caller that started it.
@@ -663,12 +604,14 @@ class McpToolServer:
663
604
  # (MJ-1), which the caller reads as operator-decision-required naming it in data.detail.
664
605
  # DEC-0125: agent_id is this service's own agent id; with it the server reads a call's _meta.mandate for a
665
606
  # relayed action added with its effect (the chain must end in agent_id, and in the delegation's caller before
666
- # it when authorize accepted a token); without it a mandate is not read, as before.
607
+ # it when authorize accepted a token); without it a mandate is not read, as before. FAA-135:
608
+ # identify_agent(headers, tier) names the agent id of a per-agent credential the service authenticated, read
609
+ # when no delegation named a caller: the chain must hold it before agent_id (identified_agent reads its answer).
667
610
  if agent_id is not None and not is_agent_id(agent_id):
668
611
  raise InteropError("agent_id is this service's agent id (a lower-case id, optionally .instance; DEC-0125).")
669
612
  self.agent_id = agent_id
670
613
  self._authorize = authorize
671
- self._identify, self._approve, self._elicited = identify, approve, elicited
614
+ self._identify, self._approve, self._elicited, self._identify_agent = identify, approve, elicited, identify_agent
672
615
  self.input_actions, self._clock, self._on_fault = input_actions is True, clock, on_fault
673
616
  self.actions: Dict[str, Dict[str, Any]] = {}
674
617
  self.info = {"name": name, "version": version}
@@ -739,6 +682,20 @@ class McpToolServer:
739
682
  authenticated = self._identify(headers, tier)
740
683
  return action_caller(tier, authenticated)
741
684
 
685
+ def _caller_agent(self, headers: Any, tier: str, verdict: Optional[Dict[str, Any]]) -> Optional[str]:
686
+ """FAA-135: the caller's agent id a mandate's chain is bound with: the delegation's caller when authorize accepted
687
+ a token, else what identify_agent names (identified_agent; a hook that raises names UNREADABLE_AGENT), else
688
+ None. Read only on a server with agent_id; never a name the call carries."""
689
+ delegated = (verdict or {}).get("caller")
690
+ if self.agent_id is None or (isinstance(delegated, str) and delegated):
691
+ return delegated if self.agent_id is not None else None
692
+ if self._identify_agent is None:
693
+ return None
694
+ try:
695
+ return identified_agent(self._identify_agent(headers, tier))
696
+ except Exception: # noqa: BLE001 - the service's own hook: an id no chain holds, so a mandate is refused
697
+ return UNREADABLE_AGENT
698
+
742
699
  def _gate_action(self, action: Dict[str, Any], name: str, arguments: Dict[str, Any], target: Any, url: Any,
743
700
  headers: Any, tier: str, meta: Dict[str, Any], caller: Optional[str],
744
701
  mandate: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
@@ -766,13 +723,12 @@ class McpToolServer:
766
723
  return action_gate(action, tier=tier, caller=caller, meta=meta, target=target, elicited=elicited, approval=approval,
767
724
  action_digest=digest, human_step_url=url, now=format_rfc3339(self._clock() * 1000), **(mandate or {}))
768
725
 
769
- def _mandate_inputs(self, entry: Dict[str, Any], arguments: Dict[str, Any], verdict: Optional[Dict[str, Any]]) -> Dict[str, Any]:
726
+ def _mandate_inputs(self, entry: Dict[str, Any], arguments: Dict[str, Any], caller_agent: Optional[str]) -> Dict[str, Any]:
770
727
  """DEC-0125: what action_gate reads a mandate with, empty on a server without agent_id. The caller's agent id is
771
- the delegation's caller when authorize accepted a token, never a name the call carries."""
728
+ _caller_agent's (the delegation's caller, else identify_agent's, FAA-135), never a name the call carries."""
772
729
  if self.agent_id is None:
773
730
  return {}
774
731
  spend, project = (self._read(entry.get(k), arguments) for k in ("spend", "project"))
775
- caller_agent = (verdict or {}).get("caller")
776
732
  # The estimate is read as a double, as the contract does: an int past the float range is infinite, and an
777
733
  # estimate that is not a finite number reads as none, which a mandate's ceiling refuses (fail closed).
778
734
  number = _real_number(spend) # by what it stores; a bool, a mock or not a number is none (seam reading 18)
@@ -799,7 +755,7 @@ class McpToolServer:
799
755
  return _UNREADABLE
800
756
 
801
757
  def _gate_inputs(self, arguments: Dict[str, Any], headers: Any, tier: str, meta: Dict[str, Any],
802
- caller: Optional[str], verdict: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
758
+ caller: Optional[str], agent: Optional[str] = None) -> Dict[str, Any]:
803
759
  """D8: each input request an answer would take is an action (id its key, tool fabric.job.get, gate relayed),
804
760
  one decision per call; the first refusal refuses the call and nothing is applied. DEC-0125: on a server with
805
761
  agent_id, a step approval_step kept is read under the call's `_meta.mandate` too (C3.3)."""
@@ -818,7 +774,6 @@ class McpToolServer:
818
774
  # A kept step's mandate is read as approval_step read it, under the job's own capability, which must
819
775
  # be a capability name (MJ-1); the action itself stays fabric.job.get, so it is a readable action.
820
776
  capability = private.get("capability")
821
- agent = (verdict or {}).get("caller")
822
777
  mandate = {"self_id": self.agent_id, "effect": step.get("effect"), "spend_usd": step.get("spendUsd"),
823
778
  "project": step.get("project"), "caller_agent": agent if isinstance(agent, str) and agent else None,
824
779
  "mandate_capability": capability if isinstance(capability, str) else None}
@@ -921,13 +876,13 @@ class McpToolServer:
921
876
  if not verdict.get("ok"):
922
877
  import fabric_delegation as _fd # noqa: E402 - only a gated server needs it
923
878
  return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
924
- caller, inputs = self._caller(headers, tier, verdict), None
879
+ caller, agent, inputs = self._caller(headers, tier, verdict), self._caller_agent(headers, tier, verdict), None
925
880
  responses = arguments.get("inputResponses")
926
881
  if name == "fabric.job.get" and responses is not None and not isinstance(responses, dict): # seam r37: never a crash
927
882
  return self._error(rid, -32602, "fabric.job.get takes inputResponses as a JSON object (interop C3.3).")
928
883
  if name == "fabric.job.get" and self.input_actions and isinstance(responses, dict) and responses:
929
884
  try:
930
- inputs = self._gate_inputs(arguments, headers, tier, meta, caller, verdict)
885
+ inputs = self._gate_inputs(arguments, headers, tier, meta, caller, agent)
931
886
  except UnknownJob:
932
887
  return {"jsonrpc": "2.0", "id": rid, "result": unknown_job_result(str(arguments.get("id", "")), traceparent=span)}
933
888
  if not inputs["ok"] and "invalid" in inputs:
@@ -937,7 +892,8 @@ class McpToolServer:
937
892
  self._fault(stored if isinstance(stored, str) else name, inputs) # as the start heard it (FAA-179)
938
893
  import fabric_delegation as _fd # noqa: E402 - DL-19
939
894
  return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(inputs["refusal"], traceparent=span)}
940
- context = {"tier": tier, "caller": caller, "meta": meta, "input_records": inputs["records"] if inputs else None}
895
+ context = {"tier": tier, "caller": caller, "meta": meta, "input_records": inputs["records"] if inputs else None,
896
+ "caller_agent": agent}
941
897
  return {"jsonrpc": "2.0", "id": rid, "result": self._job_call(name, arguments, span, verdict, context)}
942
898
  handler = self.handlers.get(name)
943
899
  if handler is None:
@@ -950,9 +906,10 @@ class McpToolServer:
950
906
  return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
951
907
  delegation = verdict
952
908
  caller, gated, entry = self._caller(headers, tier, delegation), {"record": None, "decision": None}, self.actions.get(name)
909
+ agent = self._caller_agent(headers, tier, delegation)
953
910
  if entry is not None: # DEC-0121: after the authorize gate, before the handler
954
911
  gated = self._gate_action(entry["action"], name, arguments, self._target(entry["target"], arguments), entry["url"],
955
- headers, tier, meta, caller, self._mandate_inputs(entry, arguments, delegation))
912
+ headers, tier, meta, caller, self._mandate_inputs(entry, arguments, agent))
956
913
  if not gated["ok"] and "invalid" in gated:
957
914
  return self._error(rid, -32602, gated["invalid"])
958
915
  if not gated["ok"]:
@@ -960,7 +917,7 @@ class McpToolServer:
960
917
  import fabric_delegation as _fd # noqa: E402 - DL-19
961
918
  return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(gated["refusal"], traceparent=span)}
962
919
  context = CallContext(self, name, arguments, span, delegation, tier=tier, caller=caller, meta=meta,
963
- record=gated["record"], decision=gated["decision"], mandate=gated.get("mandate"))
920
+ record=gated["record"], decision=gated["decision"], mandate=gated.get("mandate"), caller_agent=agent)
964
921
  try:
965
922
  structured = handler(arguments, context)
966
923
  except InteropError as exc:
@@ -990,7 +947,10 @@ class McpToolServer:
990
947
  matched = self.jobs.answer(job_id, responses)
991
948
  if matched and self._on_input:
992
949
  private = self.jobs.private(job_id)
993
- self._on_input(job_id, matched, CallContext(self, private["capability"], private["input"], job_span, delegation, **context))
950
+ try:
951
+ self._on_input(job_id, matched, CallContext(self, private["capability"], private["input"], job_span, delegation, **context))
952
+ except InteropError as exc: # answered as the handler path answers one (FAA-196 (27))
953
+ return tool_result({"error": {"code": "tool-error", "message": str(exc)}}, is_error=True, traceparent=job_span)
994
954
  return tool_result({"job": self.jobs.get(job_id)}, traceparent=job_span)
995
955
  except UnknownJob:
996
956
  return unknown_job_result(job_id, traceparent=span)
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
1
2
  """fabric-interop/0.1, the rule layer: the extension's names and limits, its errors, the trace context (C3.4), a
2
3
  usage object's reading (C3.2) and the job limits and uncertain effects (DEC-0087). Every function answers by a rule
3
4
  and touches no file; fabric_interop builds the tools, the envelope, the job store and the MCP server on it and
@@ -5,6 +6,7 @@ re-exports every name here. Part of the service kit (FAA-92, split from fabric_i
5
6
  only, Python 3.9+."""
6
7
  from __future__ import annotations
7
8
 
9
+ import base64
8
10
  import hashlib
9
11
  import json
10
12
  import math
@@ -20,7 +22,7 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
20
22
  from fabric_base import ServiceError # noqa: E402
21
23
  from fabric_actions import action_decision, decision_shape, job_capability # noqa: E402 - DEC-0121
22
24
  from fabric_mandate import ( # noqa: E402 - DEC-0125, DEC-0126
23
- EFFECT_CLASSES, OPERATOR_SOURCE_PROTOCOL, mandate_covers, mandate_problem, without_source)
25
+ EFFECT_CLASSES, OPERATOR_SOURCE_PROTOCOL, is_agent_id, mandate_covers, mandate_problem, without_source)
24
26
 
25
27
  PROTOCOL = "fabric-interop/0.1"
26
28
  EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
@@ -377,6 +379,22 @@ def job_step(mandate: Any, *, tier: Any, now: Any, self_id: Any, capability: Any
377
379
  UNREADABLE_PROJECT = "\x00unreadable"
378
380
 
379
381
 
382
+ #: The caller's agent id when a service's identify_agent hook answered something no chain can hold (an id the registry
383
+ #: refuses, not a string, or the hook raised): passed as callerAgent it matches no agent, so every mandate reads
384
+ #: mandate-not-for-this-call (fail closed, FAA-135). Never an agent id (`is_agent_id` refuses it).
385
+ UNREADABLE_AGENT = "\x00unreadable-agent"
386
+
387
+
388
+ def identified_agent(value: Any) -> Optional[str]:
389
+ """FAA-135: the caller's agent id as a service's own hook named it from a per-agent credential (contract delegation.md,
390
+ `surfaces.mcp.auth: own`), read as the mandate chain binds it (`callerAgent`): None (no per-agent credential) is
391
+ none, and the chain's end alone binds the mandate, as before; an agent id the registry accepts is that id; anything
392
+ else is UNREADABLE_AGENT, which no chain holds. Never a name the call itself carries."""
393
+ if value is None:
394
+ return None
395
+ return value if is_agent_id(value) else UNREADABLE_AGENT
396
+
397
+
380
398
  def mandate_spend(value: Any) -> Any:
381
399
  """A spend estimate as a mandate reads it (FAA-139): a number read as a double, as the contract does; one that is
382
400
  not a finite double (an int past the float range, NaN, infinity) or not a number is none, which a mandate's
@@ -559,3 +577,83 @@ def repeated_key_decision(job: Any, now: Any, limit_holds: Optional[bool] = None
559
577
  return "same"
560
578
  return "run" if "resetsAt" not in data or utc_time(data["resetsAt"]) <= at else "same"
561
579
  # #endregion job-limits-kit
580
+
581
+
582
+ # --- MCP 2026-07-28 header mirroring (moved from fabric_interop in 0.83.0 to make room under the line ceiling) ---
583
+
584
+ NAMED_METHODS = {"tools/call": "name", "prompts/get": "name", "resources/read": "uri"}
585
+ HEADER_MISMATCH = -32020 # the code the official 2026-07-28 server SDK answers a header/body mismatch with
586
+
587
+
588
+ _SENTINEL = ("=?base64?", "?=")
589
+
590
+
591
+ def encode_header_value(value: str) -> str:
592
+ """A body value as an HTTP header value, by MCP 2026-07-28 Streamable HTTP "Value Encoding": as
593
+ it is when it is printable ASCII without leading or trailing whitespace, otherwise (and for a
594
+ plain value that itself looks like the sentinel) `=?base64?<base64 of its UTF-8>?=`."""
595
+ plain = (all(0x20 <= ord(c) <= 0x7E for c in value) and value == value.strip(" ")
596
+ and not (value.startswith(_SENTINEL[0]) and value.endswith(_SENTINEL[1])))
597
+ if plain:
598
+ return value
599
+ return _SENTINEL[0] + base64.b64encode(value.encode("utf-8")).decode("ascii") + _SENTINEL[1]
600
+
601
+
602
+ def decode_header_value(value: str) -> Optional[str]:
603
+ """The body value a header carries, decoding the Base64 sentinel; None for a header value with
604
+ invalid characters or a sentinel that is not valid Base64 of UTF-8 (a server rejects both)."""
605
+ if value.startswith(_SENTINEL[0]) and value.endswith(_SENTINEL[1]) and len(value) >= len(_SENTINEL[0]) + len(_SENTINEL[1]):
606
+ try:
607
+ return base64.b64decode(value[len(_SENTINEL[0]):-len(_SENTINEL[1])], validate=True).decode("utf-8")
608
+ except ValueError: # binascii.Error and UnicodeDecodeError are both ValueErrors
609
+ return None
610
+ if any(not (0x20 <= ord(c) <= 0x7E or c == "\t") for c in value):
611
+ return None
612
+ return value
613
+
614
+
615
+ def mcp_header_problem(message: Any, headers: Any) -> Optional[str]:
616
+ """MCP 2026-07-28 Streamable HTTP mirrors the body in `Mcp-Method` (every request and
617
+ notification) and `Mcp-Name` (`params.name` or `params.uri` of tools/call, prompts/get,
618
+ resources/read). A server MUST reject a mismatch; a request that declares 2026-07-28 must
619
+ carry them. Earlier revisions may omit them, but what they send must still agree.
620
+ `headers` is any case-insensitive mapping with `.get` (http.server's message, a dict)."""
621
+ if not isinstance(message, dict) or headers is None:
622
+ return None
623
+ def header(name: str) -> Optional[str]:
624
+ value = headers.get(name) if hasattr(headers, "get") else None
625
+ if value is None and isinstance(headers, dict):
626
+ value = next((v for k, v in headers.items() if k.lower() == name.lower()), None)
627
+ if isinstance(value, (list, tuple)):
628
+ value = value[0] if value else None
629
+ return None if value is None else str(value)
630
+ declared = header("MCP-Protocol-Version") == MCP_REVISION
631
+ method = message.get("method")
632
+ sent = header("Mcp-Method")
633
+ if sent is None and declared:
634
+ return "the body names method %s but the required Mcp-Method header is absent" % method
635
+ if sent is not None and sent != method:
636
+ return "Mcp-Method %s disagrees with the body's method %s" % (sent, method)
637
+ field = NAMED_METHODS.get(str(method))
638
+ if field:
639
+ params = message.get("params")
640
+ value = params.get(field) if isinstance(params, dict) else None
641
+ named = header("Mcp-Name")
642
+ if named is None and declared:
643
+ return "the body carries params.%s but the required Mcp-Name header is absent" % field
644
+ if named is not None:
645
+ decoded = decode_header_value(named)
646
+ if decoded is None:
647
+ return "Mcp-Name is malformed: invalid characters or a broken =?base64?…?= value"
648
+ if decoded != str(value):
649
+ return "Mcp-Name disagrees with the body's params.%s" % field
650
+ return None
651
+
652
+
653
+ def mcp_request_headers(method: str, params: Dict[str, Any]) -> Dict[str, str]:
654
+ """The standard headers a 2026-07-28 client sends with one request."""
655
+ out = {"MCP-Protocol-Version": MCP_REVISION, "Mcp-Method": method}
656
+ field = NAMED_METHODS.get(method)
657
+ if field and params.get(field) is not None:
658
+ out["Mcp-Name"] = encode_header_value(str(params[field]))
659
+ return out
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
1
2
  """JavaScript's view of JSON values, once for the whole kit (FAA-65, layer L0).
2
3
 
3
4
  The contract's answers are computed by TypeScript, so a kit that reads JSON the way Python does disagrees with them on
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
1
2
  """The call queue and the load report of DEC-0104 (contract `src/agent-network.ts`, FAC-SEM-131 to FAC-SEM-133), as the
2
3
  contract's reference answers them: a node that hosts a job-backed agent queues its jobs per (agentRef, context) with
3
4
  these and reports its load as `service-load/1` under `extensions/load/0.1`. Part of the service kit (FAA-289, layer L3);
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
1
2
  """One copy of a service, locked before any side effect (LC-01, DEC-0057): the instance lock, the supervisor marker
2
3
  in both its forms, and the wait budget under a supervisor. Part of the service kit (FAA-65, layer L3).
3
4
 
@@ -206,15 +207,24 @@ class InstanceLock:
206
207
  return self
207
208
 
208
209
  def release(self) -> None:
210
+ """Let the lock go. Never raises, so a release in a `finally` never replaces the caller's own outcome (FAA-196
211
+ (11)): the descriptor number and the name are forgotten before they are closed, so a retry never closes a
212
+ number reused since; a close that fails (a deferred write error) still drops the name, as acquire does."""
209
213
  if self._watch is not None:
210
214
  self._watch.set()
211
215
  self._watch = None
212
- if self._fd is not None:
213
- os.close(self._fd)
214
- self._fd = None
215
- if self._name is not None:
216
- _drop_lock_name(self._name)
217
- self._name = None
216
+ fd, name, self._fd, self._name = self._fd, self._name, None, None
217
+ try:
218
+ if fd is not None:
219
+ os.close(fd)
220
+ except OSError: # the descriptor is gone either way; the kernel frees the lock with it
221
+ pass
222
+ finally:
223
+ if name is not None:
224
+ try:
225
+ _drop_lock_name(name)
226
+ except OSError: # nothing to add to the caller's own outcome
227
+ pass
218
228
 
219
229
 
220
230
  def lost_lock_message(lock_path: Path) -> str:
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
1
2
  """Logs and stopping (LC-01, LC-12): a rotating log, a capped stdout log, the stop signal and the drain.
2
3
  Part of the service kit (FAA-65, layer L3)."""
3
4
  from __future__ import annotations
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
1
2
  """The operator's mandate, `mandate/1` (DEC-0125, FAC-SEM-144): the operator's instruction for one task travels with
2
3
  the task, narrowed at each hand-over. Part of the service kit (layer L2); the twin of fabric-mandate.mjs.
3
4
 
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
1
2
  """OpenRouter chat completions for a service: its own key, a spend ceiling checked before every attempt, a usage
2
3
  receipt for every answered call, and a fallback model of another vendor.
3
4
 
@@ -72,6 +73,45 @@ def vendor(model: str) -> str:
72
73
  return str(model).split("/", 1)[0] if "/" in str(model) else ""
73
74
 
74
75
 
76
+ #: A base URL's path as both twins send it unchanged: URL path characters and %XX escapes only (FAA-187 (18)).
77
+ _BASE_PATH = re.compile(r"(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})*)*", re.ASCII)
78
+
79
+
80
+ def _dialed_host(base: str) -> str:
81
+ """The base with a host outside ASCII written as urllib dials it, in IDNA (lower case), so attribution reads the
82
+ host the call reaches, as the Node twin's `new URL` does (FAA-187 (19)): `openrouter.ai` behind a zero-width space
83
+ or a soft hyphen is openrouter.ai. check_url has already refused a host the codec cannot write."""
84
+ scheme, sep, rest = base.partition("://")
85
+ authority, slash, path = rest.partition("/")
86
+ userinfo, at, hostport = authority.rpartition("@")
87
+ if not sep or hostport.isascii() or hostport.startswith("["):
88
+ return base
89
+ host, colon, port = hostport.partition(":")
90
+ return "%s://%s%s%s%s%s%s" % (scheme, userinfo, at, host.encode("idna").decode("ascii").lower(), colon, port, slash + path)
91
+
92
+
93
+ def base_url_problem(base: str) -> Optional[str]:
94
+ """Why a base URL is not sent the same by both twins, read from the URL as sent (its ends trimmed, as `new URL` and
95
+ urllib trim them), or None: a backslash anywhere (`new URL` reads it as a slash), and after a `//` authority a
96
+ path holding anything but URL path characters and %XX escapes (Python refused a space or a character outside
97
+ ASCII, Node sent it percent-encoded; Node removes a tab or a line break) or a dot segment, escaped or not (Node
98
+ collapses it). The Node twin's `baseUrlProblem` is the same rule (FAA-187 (18), kit-openrouter-cases.json)."""
99
+ text = base.strip("".join(map(chr, range(0x21))))
100
+ if "\\" in text:
101
+ return "the base URL holds a backslash"
102
+ if any(c in text for c in "\t\n\r"): # Node's URL parser removes them anywhere and sends the rest
103
+ return "the base URL holds a tab or a line break"
104
+ rest = text.partition(":")[2]
105
+ if not rest.startswith("//"):
106
+ return None # no authority as written: check_url reads it
107
+ path = "/" + rest[2:].partition("/")[2] if "/" in rest[2:] else ""
108
+ if not _BASE_PATH.fullmatch(path):
109
+ return "the base URL's path holds a character outside URL path characters and %XX escapes"
110
+ if any(re.sub("%2[eE]", ".", seg) in (".", "..") for seg in path.split("/")[1:]):
111
+ return "the base URL's path holds a dot segment"
112
+ return None
113
+
114
+
75
115
  _DECIMAL = re.compile(r"(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?", re.ASCII)
76
116
 
77
117
 
@@ -170,6 +210,9 @@ class Client:
170
210
  raise fabric_http.HttpError("the URL does not parse", url="<unparseable url>", kind="refused")
171
211
  if "?" in base_url or "#" in base_url:
172
212
  raise OpenRouterError("the base URL holds no query or fragment", kind="config")
213
+ problem = base_url_problem(base_url)
214
+ if problem:
215
+ raise OpenRouterError(problem + " (both kits would not send it alike)", kind="config")
173
216
  fabric_http.check_url(base_url)
174
217
  _check_attribution(project_id, None)
175
218
  self.ceiling_usd = float(ceiling_usd)
@@ -179,7 +222,7 @@ class Client:
179
222
  self.window = window
180
223
  # The base URL as check_url read it and urllib would send it: its ends trimmed, so `.../v1\n` is not sent
181
224
  # as `.../v1\n/chat/completions` (0.56.0 seam r18; the Node twin trims the ends `new URL` does, product r20).
182
- self.base_url = base_url.strip(fabric_http._URL_ENDS).rstrip("/")
225
+ self.base_url = _dialed_host(base_url.strip(fabric_http._URL_ENDS).rstrip("/"))
183
226
  self.attribution = attribution
184
227
  self.project_id = project_id
185
228
  self.unmeasured_call_usd = unmeasured_call_usd