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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +122 -3
  2. package/README.md +6 -3
  3. package/SECURITY.md +4 -3
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -2
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +16 -0
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +37 -12
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +21 -148
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -8
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +3 -3
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +20 -9
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +5 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +8 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +26 -5
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -2
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +21 -7
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +6 -2
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +15 -2
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +55 -19
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +15 -2
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +27 -6
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +31 -7
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +18 -16
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +9 -5
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +15 -6
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +93 -93
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +37 -12
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +365 -53
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
  97. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
  98. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
  99. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
  100. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
  101. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
  102. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
  103. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
  104. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
  105. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  106. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  107. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
  108. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +14 -9
  109. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +7 -4
  110. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +16 -5
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +79 -8
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
@@ -50,6 +50,8 @@ from pathlib import Path as _Path # noqa: E402
50
50
  if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
51
51
  _sys.path.insert(0, str(_Path(__file__).resolve().parent))
52
52
  from fabric_jsvalue import json_stringify # noqa: E402 - beside this file
53
+ import fabric_secrets # noqa: E402 - beside this file: the WHATWG host reading (FAA-98, FAA-309)
54
+ import fabric_socket_proof # noqa: E402 - beside this file: the one loopback host rule (FAA-187 (15))
53
55
  import fabric_peer_owner as _po # noqa: E402 - beside this file: the listener's owner (DEC-0154 HA-2)
54
56
 
55
57
  RETRY_STATUSES = (408, 425, 429, 500, 502, 503, 504)
@@ -211,28 +213,28 @@ def redact_url(url: str) -> str:
211
213
  return urllib.parse.urlunsplit((parts.scheme.lower(), netloc, path, "", ""))
212
214
 
213
215
 
214
- def _is_loopback(host: str) -> bool:
215
- """Loopback as a WHATWG URL parser leaves the host: one trailing dot dropped from an IPv4 address, no whitespace,
216
- and an IPv4-mapped IPv6 address not counted (Node writes it in hex and the Node twin refuses it)."""
217
- if host == "localhost" or host.endswith(".localhost"):
216
+ #: Loopback as a WHATWG URL parser leaves the host: the kit's one rule, shared with fabric_delegation (FAA-187 (15)).
217
+ _is_loopback = fabric_socket_proof.loopback_host
218
+
219
+
220
+ def _idna_sendable(host: Optional[str]) -> bool:
221
+ """Whether http.client can write the host: a non-ASCII one goes out in IDNA through Python's own codec, which refuses
222
+ some hosts `new URL` maps (U+180E, a fullwidth dot beside a dot), so such a URL would fail on every call (FAA-187
223
+ (16)); it is refused at the check instead."""
224
+ if not host or host.isascii():
218
225
  return True
219
- if not host or any(c.isspace() for c in host):
220
- return False
221
226
  try:
222
- address = ipaddress.ip_address(host)
223
- return address.is_loopback and not getattr(address, "ipv4_mapped", None)
224
- except ValueError:
225
- pass
226
- host = host[:-1] if host.endswith(".") else host
227
- try: # the short IPv4 forms a WHATWG URL also reads (127.1, 2130706433, 0x7f.1)
228
- return socket.inet_aton(host)[0] == 127
229
- except (OSError, ValueError):
227
+ host.encode("idna")
228
+ return True
229
+ except UnicodeError:
230
230
  return False
231
231
 
232
232
 
233
233
  def check_url(url: str, *, insecure: bool = False) -> None:
234
234
  """Refuse what this client never sends: a scheme other than http or https, no host, credentials in the URL, or
235
- plain http to a host that is not loopback (unless `insecure`)."""
235
+ plain http to a host that is not loopback (unless `insecure`). A host `new URL` refuses for its structure (a last
236
+ label that is a number but no IPv4 address, `a127.0.0.1`, `09`; a host UTS46 refuses, a joiner beside ASCII alone) is
237
+ refused as the Node twin refuses it, read by fabric_secrets.url_refused (FAA-187 (14))."""
236
238
  try:
237
239
  # The ends are trimmed as urllib trims what it sends (and `new URL` too); a URL ending in a line break, which
238
240
  # 0.55.0 sent, still passes (0.56.0 product r17).
@@ -242,7 +244,7 @@ def check_url(url: str, *, insecure: bool = False) -> None:
242
244
  parts = urllib.parse.urlsplit(sent)
243
245
  host = parts.hostname
244
246
  parts.port # noqa: B018 - raises on a bad port
245
- if not _authority_ok(parts.netloc):
247
+ if not _authority_ok(parts.netloc) or fabric_secrets.url_refused(sent) or not _idna_sendable(host):
246
248
  raise ValueError("authority")
247
249
  except (ValueError, TypeError):
248
250
  raise HttpError("the URL does not parse", url=redact_url(url), kind="refused")
@@ -96,20 +96,24 @@ def fabric_root(platform: str = sys.platform, env: Optional[Dict[str, str]] = No
96
96
 
97
97
 
98
98
  def _windows_pid_alive(pid: int, kernel: Any = None, last_error: Any = None) -> Optional[bool]:
99
- """pid_alive on Windows, where os.kill(pid, 0) would send CTRL_C_EVENT: OpenProcess with query access only. Refused
100
- with ERROR_ACCESS_DENIED (5), another account's or an elevated process: None, as Node's process.kill(pid, 0)
101
- answers EPERM there (FAA-304); refused otherwise, no process. `kernel` and `last_error` stand in for kernel32 and
99
+ """pid_alive on Windows, where os.kill(pid, 0) would send CTRL_C_EVENT: the check Node's process.kill(pid, 0) makes
100
+ (libuv's uv_kill), asking OpenProcess for the same access (terminate, query, synchronize; never the limited query an
101
+ elevated process of the same user grants, FAA-323). Refused with ERROR_ACCESS_DENIED (5), another account's or an
102
+ elevated process: None, as Node answers EPERM there (FAA-304); refused otherwise, no process; opened, running
103
+ while its exit code is STILL_ACTIVE and a zero wait times out. `kernel` and `last_error` stand in for kernel32 and
102
104
  ctypes.get_last_error in a test."""
103
105
  import ctypes
104
106
  if kernel is None:
105
107
  kernel = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined]
106
108
  last_error = ctypes.get_last_error # type: ignore[attr-defined]
107
- handle = kernel.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
109
+ handle = kernel.OpenProcess(0x0001 | 0x0400 | 0x00100000, False, pid) # TERMINATE | QUERY_INFORMATION | SYNCHRONIZE
108
110
  if not handle:
109
111
  return None if last_error() == 5 else False # ERROR_ACCESS_DENIED: not verified, never called running
110
112
  try:
111
113
  code = ctypes.c_ulong()
112
- return bool(kernel.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
114
+ if not kernel.GetExitCodeProcess(handle, ctypes.byref(code)) or code.value != 259: # STILL_ACTIVE
115
+ return False
116
+ return kernel.WaitForSingleObject(handle, 0) != 0 # WAIT_OBJECT_0: ended, with exit code 259
113
117
  finally:
114
118
  kernel.CloseHandle(handle)
115
119
 
@@ -17,7 +17,6 @@ Normative source: fabric-agent-contract docs/specification/interop.md (DEC-0016,
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
- import base64
21
20
  import hashlib
22
21
  import json
23
22
  import math
@@ -98,6 +97,14 @@ from fabric_interop_rules import ( # noqa: E402,F401 - the rule layer (FAA-92)
98
97
  pause_message,
99
98
  intent_digest,
100
99
  repeated_key_decision,
100
+ NAMED_METHODS,
101
+ UNREADABLE_AGENT,
102
+ identified_agent,
103
+ HEADER_MISMATCH,
104
+ encode_header_value,
105
+ decode_header_value,
106
+ mcp_header_problem,
107
+ mcp_request_headers,
101
108
  )
102
109
 
103
110
  # --- C3.1 capabilities as tools --------------------------------------------------
@@ -399,6 +406,8 @@ class JobStore:
399
406
  A step is kept as the gate reads it (kept_step), so a job's start and its answer agree."""
400
407
  if not isinstance(input_requests, dict) or not input_requests: # a JSON object of requests, first (readings 38, 39)
401
408
  raise InteropError("input_required needs a JSON object of at least one input request.")
409
+ if steps is not None and not isinstance(steps, dict): # a JSON object or none, in both kits (FAA-196 (26))
410
+ raise InteropError("steps maps an input request's key to its approval step: a JSON object, or none.")
402
411
  kept: Dict[str, Dict[str, Any]] = {}
403
412
  for key, step in (steps or {}).items(): # the value checked is the value kept (seam reading 34)
404
413
  kept[key] = kept_step(step) if key in input_requests and isinstance(step, dict) else {}
@@ -493,7 +502,8 @@ class CallContext:
493
502
  def __init__(self, server: "McpToolServer", tool: str, arguments: Any, traceparent: str,
494
503
  delegation: Optional[Dict[str, Any]] = None, *, tier: Optional[str] = None, caller: Optional[str] = None,
495
504
  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):
505
+ input_records: Optional[Dict[str, Any]] = None, mandate: Optional[Dict[str, Any]] = None,
506
+ caller_agent: Optional[str] = None):
497
507
  self.server = server
498
508
  self.tool = tool
499
509
  self.arguments = arguments
@@ -503,12 +513,19 @@ class CallContext:
503
513
  self.tier, self.caller, self.meta = tier, caller, {} if meta is None else meta
504
514
  self.record, self.decision, self.input_records = record, decision, input_records
505
515
  self.mandate = mandate
516
+ # FAA-135: the caller's agent id the service authenticated (the delegation's caller, else identify_agent's).
517
+ delegated = (delegation or {}).get("caller")
518
+ self.caller_agent = caller_agent if caller_agent is not None else (delegated if isinstance(delegated, str) and delegated else None)
506
519
 
507
520
  def start_job(self, poll_interval_ms: Optional[int] = None) -> Dict[str, Any]:
508
521
  if self.server.jobs is None:
509
522
  raise InteropError("This server has no job store.")
510
523
  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)
524
+ try:
525
+ self.server.job_started(handle["job"]["id"], self)
526
+ except InteropError as exc: # a refused start: the job it created ends with it, never left working (FAA-196 (28))
527
+ self.server.jobs.fail(handle["job"]["id"], "tool-error", str(exc)[:_MAX_ERROR_MESSAGE] or "The job's start was refused.")
528
+ raise
512
529
  return handle
513
530
 
514
531
  def approval_step(self, job_id: str, key: str, request: Dict[str, Any], *, effect: str, spend_usd: Any = None,
@@ -527,7 +544,7 @@ class CallContext:
527
544
  stored = self.server.jobs.private(job_id).get("capability")
528
545
  capability = stored if isinstance(stored, str) else self.tool
529
546
  if self.server.agent_id is not None:
530
- caller = (self.delegation or {}).get("caller")
547
+ caller = self.caller_agent
531
548
  carried = self.meta.get("mandate")
532
549
  if carried is None and "mandate" in self.meta:
533
550
  carried = {} # a null mandate is present and unreadable, as the answer reads it; never "none" (FAA-178)
@@ -550,84 +567,6 @@ JOB_REQUEST_SCHEMA = {"type": "object", "required": ["id"], "additionalPropertie
550
567
  "inputResponses": {"type": "object"}}}
551
568
 
552
569
 
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
570
  _UNREADABLE = object() # what a service's spend or project function answers when it raised
632
571
  _UNREADABLE_PROJECT = UNREADABLE_PROJECT # never a member of a mandate's projects, so a scoped mandate refuses
633
572
 
@@ -649,7 +588,8 @@ class McpToolServer:
649
588
  approve: Optional[Callable[[str, Dict[str, Any], Any, str], Optional[Dict[str, Any]]]] = None,
650
589
  elicited: Optional[Callable[[str, Dict[str, Any], Optional[str]], bool]] = None,
651
590
  input_actions: bool = False, clock: Callable[[], float] = time.time,
652
- on_fault: Optional[Callable[[str, str], None]] = None, agent_id: Optional[str] = None):
591
+ on_fault: Optional[Callable[[str, str], None]] = None, agent_id: Optional[str] = None,
592
+ identify_agent: Optional[Callable[[Any, str], Optional[str]]] = None):
653
593
  # authorize(tool, headers, tier=) runs before every handler (fabric_delegation.delegation_gate, DEC-0039):
654
594
  # a refusal is answered in the DL-19 shape and the handler never runs. The built-in job tools are
655
595
  # gated by the job's own capability, and answered only for the principal and caller that started it.
@@ -663,12 +603,14 @@ class McpToolServer:
663
603
  # (MJ-1), which the caller reads as operator-decision-required naming it in data.detail.
664
604
  # DEC-0125: agent_id is this service's own agent id; with it the server reads a call's _meta.mandate for a
665
605
  # 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.
606
+ # it when authorize accepted a token); without it a mandate is not read, as before. FAA-135:
607
+ # identify_agent(headers, tier) names the agent id of a per-agent credential the service authenticated, read
608
+ # when no delegation named a caller: the chain must hold it before agent_id (identified_agent reads its answer).
667
609
  if agent_id is not None and not is_agent_id(agent_id):
668
610
  raise InteropError("agent_id is this service's agent id (a lower-case id, optionally .instance; DEC-0125).")
669
611
  self.agent_id = agent_id
670
612
  self._authorize = authorize
671
- self._identify, self._approve, self._elicited = identify, approve, elicited
613
+ self._identify, self._approve, self._elicited, self._identify_agent = identify, approve, elicited, identify_agent
672
614
  self.input_actions, self._clock, self._on_fault = input_actions is True, clock, on_fault
673
615
  self.actions: Dict[str, Dict[str, Any]] = {}
674
616
  self.info = {"name": name, "version": version}
@@ -739,6 +681,20 @@ class McpToolServer:
739
681
  authenticated = self._identify(headers, tier)
740
682
  return action_caller(tier, authenticated)
741
683
 
684
+ def _caller_agent(self, headers: Any, tier: str, verdict: Optional[Dict[str, Any]]) -> Optional[str]:
685
+ """FAA-135: the caller's agent id a mandate's chain is bound with: the delegation's caller when authorize accepted
686
+ a token, else what identify_agent names (identified_agent; a hook that raises names UNREADABLE_AGENT), else
687
+ None. Read only on a server with agent_id; never a name the call carries."""
688
+ delegated = (verdict or {}).get("caller")
689
+ if self.agent_id is None or (isinstance(delegated, str) and delegated):
690
+ return delegated if self.agent_id is not None else None
691
+ if self._identify_agent is None:
692
+ return None
693
+ try:
694
+ return identified_agent(self._identify_agent(headers, tier))
695
+ except Exception: # noqa: BLE001 - the service's own hook: an id no chain holds, so a mandate is refused
696
+ return UNREADABLE_AGENT
697
+
742
698
  def _gate_action(self, action: Dict[str, Any], name: str, arguments: Dict[str, Any], target: Any, url: Any,
743
699
  headers: Any, tier: str, meta: Dict[str, Any], caller: Optional[str],
744
700
  mandate: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
@@ -766,13 +722,12 @@ class McpToolServer:
766
722
  return action_gate(action, tier=tier, caller=caller, meta=meta, target=target, elicited=elicited, approval=approval,
767
723
  action_digest=digest, human_step_url=url, now=format_rfc3339(self._clock() * 1000), **(mandate or {}))
768
724
 
769
- def _mandate_inputs(self, entry: Dict[str, Any], arguments: Dict[str, Any], verdict: Optional[Dict[str, Any]]) -> Dict[str, Any]:
725
+ def _mandate_inputs(self, entry: Dict[str, Any], arguments: Dict[str, Any], caller_agent: Optional[str]) -> Dict[str, Any]:
770
726
  """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."""
727
+ _caller_agent's (the delegation's caller, else identify_agent's, FAA-135), never a name the call carries."""
772
728
  if self.agent_id is None:
773
729
  return {}
774
730
  spend, project = (self._read(entry.get(k), arguments) for k in ("spend", "project"))
775
- caller_agent = (verdict or {}).get("caller")
776
731
  # The estimate is read as a double, as the contract does: an int past the float range is infinite, and an
777
732
  # estimate that is not a finite number reads as none, which a mandate's ceiling refuses (fail closed).
778
733
  number = _real_number(spend) # by what it stores; a bool, a mock or not a number is none (seam reading 18)
@@ -799,7 +754,7 @@ class McpToolServer:
799
754
  return _UNREADABLE
800
755
 
801
756
  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]:
757
+ caller: Optional[str], agent: Optional[str] = None) -> Dict[str, Any]:
803
758
  """D8: each input request an answer would take is an action (id its key, tool fabric.job.get, gate relayed),
804
759
  one decision per call; the first refusal refuses the call and nothing is applied. DEC-0125: on a server with
805
760
  agent_id, a step approval_step kept is read under the call's `_meta.mandate` too (C3.3)."""
@@ -818,7 +773,6 @@ class McpToolServer:
818
773
  # A kept step's mandate is read as approval_step read it, under the job's own capability, which must
819
774
  # be a capability name (MJ-1); the action itself stays fabric.job.get, so it is a readable action.
820
775
  capability = private.get("capability")
821
- agent = (verdict or {}).get("caller")
822
776
  mandate = {"self_id": self.agent_id, "effect": step.get("effect"), "spend_usd": step.get("spendUsd"),
823
777
  "project": step.get("project"), "caller_agent": agent if isinstance(agent, str) and agent else None,
824
778
  "mandate_capability": capability if isinstance(capability, str) else None}
@@ -921,13 +875,13 @@ class McpToolServer:
921
875
  if not verdict.get("ok"):
922
876
  import fabric_delegation as _fd # noqa: E402 - only a gated server needs it
923
877
  return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
924
- caller, inputs = self._caller(headers, tier, verdict), None
878
+ caller, agent, inputs = self._caller(headers, tier, verdict), self._caller_agent(headers, tier, verdict), None
925
879
  responses = arguments.get("inputResponses")
926
880
  if name == "fabric.job.get" and responses is not None and not isinstance(responses, dict): # seam r37: never a crash
927
881
  return self._error(rid, -32602, "fabric.job.get takes inputResponses as a JSON object (interop C3.3).")
928
882
  if name == "fabric.job.get" and self.input_actions and isinstance(responses, dict) and responses:
929
883
  try:
930
- inputs = self._gate_inputs(arguments, headers, tier, meta, caller, verdict)
884
+ inputs = self._gate_inputs(arguments, headers, tier, meta, caller, agent)
931
885
  except UnknownJob:
932
886
  return {"jsonrpc": "2.0", "id": rid, "result": unknown_job_result(str(arguments.get("id", "")), traceparent=span)}
933
887
  if not inputs["ok"] and "invalid" in inputs:
@@ -937,7 +891,8 @@ class McpToolServer:
937
891
  self._fault(stored if isinstance(stored, str) else name, inputs) # as the start heard it (FAA-179)
938
892
  import fabric_delegation as _fd # noqa: E402 - DL-19
939
893
  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}
894
+ context = {"tier": tier, "caller": caller, "meta": meta, "input_records": inputs["records"] if inputs else None,
895
+ "caller_agent": agent}
941
896
  return {"jsonrpc": "2.0", "id": rid, "result": self._job_call(name, arguments, span, verdict, context)}
942
897
  handler = self.handlers.get(name)
943
898
  if handler is None:
@@ -950,9 +905,10 @@ class McpToolServer:
950
905
  return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
951
906
  delegation = verdict
952
907
  caller, gated, entry = self._caller(headers, tier, delegation), {"record": None, "decision": None}, self.actions.get(name)
908
+ agent = self._caller_agent(headers, tier, delegation)
953
909
  if entry is not None: # DEC-0121: after the authorize gate, before the handler
954
910
  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))
911
+ headers, tier, meta, caller, self._mandate_inputs(entry, arguments, agent))
956
912
  if not gated["ok"] and "invalid" in gated:
957
913
  return self._error(rid, -32602, gated["invalid"])
958
914
  if not gated["ok"]:
@@ -960,7 +916,7 @@ class McpToolServer:
960
916
  import fabric_delegation as _fd # noqa: E402 - DL-19
961
917
  return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(gated["refusal"], traceparent=span)}
962
918
  context = CallContext(self, name, arguments, span, delegation, tier=tier, caller=caller, meta=meta,
963
- record=gated["record"], decision=gated["decision"], mandate=gated.get("mandate"))
919
+ record=gated["record"], decision=gated["decision"], mandate=gated.get("mandate"), caller_agent=agent)
964
920
  try:
965
921
  structured = handler(arguments, context)
966
922
  except InteropError as exc:
@@ -990,7 +946,10 @@ class McpToolServer:
990
946
  matched = self.jobs.answer(job_id, responses)
991
947
  if matched and self._on_input:
992
948
  private = self.jobs.private(job_id)
993
- self._on_input(job_id, matched, CallContext(self, private["capability"], private["input"], job_span, delegation, **context))
949
+ try:
950
+ self._on_input(job_id, matched, CallContext(self, private["capability"], private["input"], job_span, delegation, **context))
951
+ except InteropError as exc: # answered as the handler path answers one (FAA-196 (27))
952
+ return tool_result({"error": {"code": "tool-error", "message": str(exc)}}, is_error=True, traceparent=job_span)
994
953
  return tool_result({"job": self.jobs.get(job_id)}, traceparent=job_span)
995
954
  except UnknownJob:
996
955
  return unknown_job_result(job_id, traceparent=span)
@@ -5,6 +5,7 @@ re-exports every name here. Part of the service kit (FAA-92, split from fabric_i
5
5
  only, Python 3.9+."""
6
6
  from __future__ import annotations
7
7
 
8
+ import base64
8
9
  import hashlib
9
10
  import json
10
11
  import math
@@ -20,7 +21,7 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
20
21
  from fabric_base import ServiceError # noqa: E402
21
22
  from fabric_actions import action_decision, decision_shape, job_capability # noqa: E402 - DEC-0121
22
23
  from fabric_mandate import ( # noqa: E402 - DEC-0125, DEC-0126
23
- EFFECT_CLASSES, OPERATOR_SOURCE_PROTOCOL, mandate_covers, mandate_problem, without_source)
24
+ EFFECT_CLASSES, OPERATOR_SOURCE_PROTOCOL, is_agent_id, mandate_covers, mandate_problem, without_source)
24
25
 
25
26
  PROTOCOL = "fabric-interop/0.1"
26
27
  EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
@@ -377,6 +378,22 @@ def job_step(mandate: Any, *, tier: Any, now: Any, self_id: Any, capability: Any
377
378
  UNREADABLE_PROJECT = "\x00unreadable"
378
379
 
379
380
 
381
+ #: The caller's agent id when a service's identify_agent hook answered something no chain can hold (an id the registry
382
+ #: refuses, not a string, or the hook raised): passed as callerAgent it matches no agent, so every mandate reads
383
+ #: mandate-not-for-this-call (fail closed, FAA-135). Never an agent id (`is_agent_id` refuses it).
384
+ UNREADABLE_AGENT = "\x00unreadable-agent"
385
+
386
+
387
+ def identified_agent(value: Any) -> Optional[str]:
388
+ """FAA-135: the caller's agent id as a service's own hook named it from a per-agent credential (contract delegation.md,
389
+ `surfaces.mcp.auth: own`), read as the mandate chain binds it (`callerAgent`): None (no per-agent credential) is
390
+ none, and the chain's end alone binds the mandate, as before; an agent id the registry accepts is that id; anything
391
+ else is UNREADABLE_AGENT, which no chain holds. Never a name the call itself carries."""
392
+ if value is None:
393
+ return None
394
+ return value if is_agent_id(value) else UNREADABLE_AGENT
395
+
396
+
380
397
  def mandate_spend(value: Any) -> Any:
381
398
  """A spend estimate as a mandate reads it (FAA-139): a number read as a double, as the contract does; one that is
382
399
  not a finite double (an int past the float range, NaN, infinity) or not a number is none, which a mandate's
@@ -559,3 +576,83 @@ def repeated_key_decision(job: Any, now: Any, limit_holds: Optional[bool] = None
559
576
  return "same"
560
577
  return "run" if "resetsAt" not in data or utc_time(data["resetsAt"]) <= at else "same"
561
578
  # #endregion job-limits-kit
579
+
580
+
581
+ # --- MCP 2026-07-28 header mirroring (moved from fabric_interop in 0.83.0 to make room under the line ceiling) ---
582
+
583
+ NAMED_METHODS = {"tools/call": "name", "prompts/get": "name", "resources/read": "uri"}
584
+ HEADER_MISMATCH = -32020 # the code the official 2026-07-28 server SDK answers a header/body mismatch with
585
+
586
+
587
+ _SENTINEL = ("=?base64?", "?=")
588
+
589
+
590
+ def encode_header_value(value: str) -> str:
591
+ """A body value as an HTTP header value, by MCP 2026-07-28 Streamable HTTP "Value Encoding": as
592
+ it is when it is printable ASCII without leading or trailing whitespace, otherwise (and for a
593
+ plain value that itself looks like the sentinel) `=?base64?<base64 of its UTF-8>?=`."""
594
+ plain = (all(0x20 <= ord(c) <= 0x7E for c in value) and value == value.strip(" ")
595
+ and not (value.startswith(_SENTINEL[0]) and value.endswith(_SENTINEL[1])))
596
+ if plain:
597
+ return value
598
+ return _SENTINEL[0] + base64.b64encode(value.encode("utf-8")).decode("ascii") + _SENTINEL[1]
599
+
600
+
601
+ def decode_header_value(value: str) -> Optional[str]:
602
+ """The body value a header carries, decoding the Base64 sentinel; None for a header value with
603
+ invalid characters or a sentinel that is not valid Base64 of UTF-8 (a server rejects both)."""
604
+ if value.startswith(_SENTINEL[0]) and value.endswith(_SENTINEL[1]) and len(value) >= len(_SENTINEL[0]) + len(_SENTINEL[1]):
605
+ try:
606
+ return base64.b64decode(value[len(_SENTINEL[0]):-len(_SENTINEL[1])], validate=True).decode("utf-8")
607
+ except ValueError: # binascii.Error and UnicodeDecodeError are both ValueErrors
608
+ return None
609
+ if any(not (0x20 <= ord(c) <= 0x7E or c == "\t") for c in value):
610
+ return None
611
+ return value
612
+
613
+
614
+ def mcp_header_problem(message: Any, headers: Any) -> Optional[str]:
615
+ """MCP 2026-07-28 Streamable HTTP mirrors the body in `Mcp-Method` (every request and
616
+ notification) and `Mcp-Name` (`params.name` or `params.uri` of tools/call, prompts/get,
617
+ resources/read). A server MUST reject a mismatch; a request that declares 2026-07-28 must
618
+ carry them. Earlier revisions may omit them, but what they send must still agree.
619
+ `headers` is any case-insensitive mapping with `.get` (http.server's message, a dict)."""
620
+ if not isinstance(message, dict) or headers is None:
621
+ return None
622
+ def header(name: str) -> Optional[str]:
623
+ value = headers.get(name) if hasattr(headers, "get") else None
624
+ if value is None and isinstance(headers, dict):
625
+ value = next((v for k, v in headers.items() if k.lower() == name.lower()), None)
626
+ if isinstance(value, (list, tuple)):
627
+ value = value[0] if value else None
628
+ return None if value is None else str(value)
629
+ declared = header("MCP-Protocol-Version") == MCP_REVISION
630
+ method = message.get("method")
631
+ sent = header("Mcp-Method")
632
+ if sent is None and declared:
633
+ return "the body names method %s but the required Mcp-Method header is absent" % method
634
+ if sent is not None and sent != method:
635
+ return "Mcp-Method %s disagrees with the body's method %s" % (sent, method)
636
+ field = NAMED_METHODS.get(str(method))
637
+ if field:
638
+ params = message.get("params")
639
+ value = params.get(field) if isinstance(params, dict) else None
640
+ named = header("Mcp-Name")
641
+ if named is None and declared:
642
+ return "the body carries params.%s but the required Mcp-Name header is absent" % field
643
+ if named is not None:
644
+ decoded = decode_header_value(named)
645
+ if decoded is None:
646
+ return "Mcp-Name is malformed: invalid characters or a broken =?base64?…?= value"
647
+ if decoded != str(value):
648
+ return "Mcp-Name disagrees with the body's params.%s" % field
649
+ return None
650
+
651
+
652
+ def mcp_request_headers(method: str, params: Dict[str, Any]) -> Dict[str, str]:
653
+ """The standard headers a 2026-07-28 client sends with one request."""
654
+ out = {"MCP-Protocol-Version": MCP_REVISION, "Mcp-Method": method}
655
+ field = NAMED_METHODS.get(method)
656
+ if field and params.get(field) is not None:
657
+ out["Mcp-Name"] = encode_header_value(str(params[field]))
658
+ return out
@@ -206,15 +206,24 @@ class InstanceLock:
206
206
  return self
207
207
 
208
208
  def release(self) -> None:
209
+ """Let the lock go. Never raises, so a release in a `finally` never replaces the caller's own outcome (FAA-196
210
+ (11)): the descriptor number and the name are forgotten before they are closed, so a retry never closes a
211
+ number reused since; a close that fails (a deferred write error) still drops the name, as acquire does."""
209
212
  if self._watch is not None:
210
213
  self._watch.set()
211
214
  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
215
+ fd, name, self._fd, self._name = self._fd, self._name, None, None
216
+ try:
217
+ if fd is not None:
218
+ os.close(fd)
219
+ except OSError: # the descriptor is gone either way; the kernel frees the lock with it
220
+ pass
221
+ finally:
222
+ if name is not None:
223
+ try:
224
+ _drop_lock_name(name)
225
+ except OSError: # nothing to add to the caller's own outcome
226
+ pass
218
227
 
219
228
 
220
229
  def lost_lock_message(lock_path: Path) -> str:
@@ -72,6 +72,45 @@ def vendor(model: str) -> str:
72
72
  return str(model).split("/", 1)[0] if "/" in str(model) else ""
73
73
 
74
74
 
75
+ #: A base URL's path as both twins send it unchanged: URL path characters and %XX escapes only (FAA-187 (18)).
76
+ _BASE_PATH = re.compile(r"(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})*)*", re.ASCII)
77
+
78
+
79
+ def _dialed_host(base: str) -> str:
80
+ """The base with a host outside ASCII written as urllib dials it, in IDNA (lower case), so attribution reads the
81
+ host the call reaches, as the Node twin's `new URL` does (FAA-187 (19)): `openrouter.ai` behind a zero-width space
82
+ or a soft hyphen is openrouter.ai. check_url has already refused a host the codec cannot write."""
83
+ scheme, sep, rest = base.partition("://")
84
+ authority, slash, path = rest.partition("/")
85
+ userinfo, at, hostport = authority.rpartition("@")
86
+ if not sep or hostport.isascii() or hostport.startswith("["):
87
+ return base
88
+ host, colon, port = hostport.partition(":")
89
+ return "%s://%s%s%s%s%s%s" % (scheme, userinfo, at, host.encode("idna").decode("ascii").lower(), colon, port, slash + path)
90
+
91
+
92
+ def base_url_problem(base: str) -> Optional[str]:
93
+ """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
94
+ urllib trim them), or None: a backslash anywhere (`new URL` reads it as a slash), and after a `//` authority a
95
+ path holding anything but URL path characters and %XX escapes (Python refused a space or a character outside
96
+ ASCII, Node sent it percent-encoded; Node removes a tab or a line break) or a dot segment, escaped or not (Node
97
+ collapses it). The Node twin's `baseUrlProblem` is the same rule (FAA-187 (18), kit-openrouter-cases.json)."""
98
+ text = base.strip("".join(map(chr, range(0x21))))
99
+ if "\\" in text:
100
+ return "the base URL holds a backslash"
101
+ if any(c in text for c in "\t\n\r"): # Node's URL parser removes them anywhere and sends the rest
102
+ return "the base URL holds a tab or a line break"
103
+ rest = text.partition(":")[2]
104
+ if not rest.startswith("//"):
105
+ return None # no authority as written: check_url reads it
106
+ path = "/" + rest[2:].partition("/")[2] if "/" in rest[2:] else ""
107
+ if not _BASE_PATH.fullmatch(path):
108
+ return "the base URL's path holds a character outside URL path characters and %XX escapes"
109
+ if any(re.sub("%2[eE]", ".", seg) in (".", "..") for seg in path.split("/")[1:]):
110
+ return "the base URL's path holds a dot segment"
111
+ return None
112
+
113
+
75
114
  _DECIMAL = re.compile(r"(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?", re.ASCII)
76
115
 
77
116
 
@@ -170,6 +209,9 @@ class Client:
170
209
  raise fabric_http.HttpError("the URL does not parse", url="<unparseable url>", kind="refused")
171
210
  if "?" in base_url or "#" in base_url:
172
211
  raise OpenRouterError("the base URL holds no query or fragment", kind="config")
212
+ problem = base_url_problem(base_url)
213
+ if problem:
214
+ raise OpenRouterError(problem + " (both kits would not send it alike)", kind="config")
173
215
  fabric_http.check_url(base_url)
174
216
  _check_attribution(project_id, None)
175
217
  self.ceiling_usd = float(ceiling_usd)
@@ -179,7 +221,7 @@ class Client:
179
221
  self.window = window
180
222
  # The base URL as check_url read it and urllib would send it: its ends trimmed, so `.../v1\n` is not sent
181
223
  # 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("/")
224
+ self.base_url = _dialed_host(base_url.strip(fabric_http._URL_ENDS).rstrip("/"))
183
225
  self.attribution = attribution
184
226
  self.project_id = project_id
185
227
  self.unmeasured_call_usd = unmeasured_call_usd