@passioncode-ai/fabric-agent-adapter 0.81.0 → 0.83.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +200 -3
- package/README.md +11 -6
- package/SECURITY.md +8 -5
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +25 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +274 -17
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +24 -151
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +3 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +17 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +24 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +14 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +23 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +28 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +30 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +22 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +29 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +27 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +21 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +88 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +133 -30
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +44 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +35 -159
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +43 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +21 -18
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +39 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +92 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +180 -162
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +274 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +465 -55
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +30 -17
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +53 -35
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +34 -21
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +96 -15
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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],
|
|
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
|
|
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],
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py
CHANGED
|
@@ -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
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
"""One copy of a service, locked before any side effect (LC-01, DEC-0057): the instance lock, the supervisor marker
|
|
2
|
-
in both its forms, and the wait budget under a supervisor. Part of the service kit (FAA-65, layer L3).
|
|
2
|
+
in both its forms, and the wait budget under a supervisor. Part of the service kit (FAA-65, layer L3).
|
|
3
|
+
|
|
4
|
+
A lock that moves under its holder (FAA-303). Linux and Windows hold a name keyed by the lock's path beside the file
|
|
5
|
+
lock (an abstract unix socket, a named pipe), so a folder swapped under a running copy cannot let a second copy in.
|
|
6
|
+
macOS has no such name: flock (and the Node twin's O_EXLOCK) lock an inode, so after a data import swaps the data
|
|
7
|
+
folder a second copy locks the new file and starts. There the holder watches its lock instead
|
|
8
|
+
(``InstanceLock.watch``, started by ``hold_single_instance``): every ``LOCK_WATCH_SECONDS`` it compares the inode it
|
|
9
|
+
locked with the file at the path, and once the path names another file it stops with 75 and one sentence naming the
|
|
10
|
+
path, before writing anything more into a folder that is no longer the one it read. Two copies then overlap for at
|
|
11
|
+
most one interval. A path-keyed name on macOS was weighed and left: it needs a file in a folder outside the data folder
|
|
12
|
+
that the system never cleans and every copy can write, which the standard library cannot promise."""
|
|
3
13
|
from __future__ import annotations
|
|
4
14
|
|
|
5
15
|
import errno
|
|
@@ -10,6 +20,7 @@ import posixpath
|
|
|
10
20
|
import re
|
|
11
21
|
import socket
|
|
12
22
|
import sys
|
|
23
|
+
import threading
|
|
13
24
|
import time
|
|
14
25
|
from pathlib import Path
|
|
15
26
|
from typing import Any, Callable, Dict, List, Optional, Sequence
|
|
@@ -46,6 +57,11 @@ LOCK_BACKOFF_FIRST_SECONDS = 0.5
|
|
|
46
57
|
LOCK_BACKOFF_MAX_SECONDS = 30.0
|
|
47
58
|
|
|
48
59
|
|
|
60
|
+
#: How often a holder that has no path-keyed name (macOS) compares the inode it locked with the file at the lock's
|
|
61
|
+
#: path (FAA-303): one stat and one fstat; two copies overlap for at most this long after a folder swap.
|
|
62
|
+
LOCK_WATCH_SECONDS = 2.0
|
|
63
|
+
|
|
64
|
+
|
|
49
65
|
class AlreadyRunning(ServiceError):
|
|
50
66
|
def __init__(self, holder_pid: Optional[int], lock_path: Path):
|
|
51
67
|
self.holder_pid = holder_pid
|
|
@@ -67,6 +83,7 @@ class InstanceLock:
|
|
|
67
83
|
self.path = Path(data_dir) / "service.lock"
|
|
68
84
|
self._fd: Optional[int] = None
|
|
69
85
|
self._name: Any = None
|
|
86
|
+
self._watch: Optional[threading.Event] = None
|
|
70
87
|
|
|
71
88
|
#: Windows locks a byte range, not the file: one byte far past the pid the file holds, so the
|
|
72
89
|
#: holder may rewrite the pid and another process may still read it (locking past the end of
|
|
@@ -157,13 +174,72 @@ class InstanceLock:
|
|
|
157
174
|
self._fd = fd
|
|
158
175
|
return True
|
|
159
176
|
|
|
177
|
+
def watch(self, on_lost: Optional[Callable[["InstanceLock"], None]] = None,
|
|
178
|
+
interval: float = LOCK_WATCH_SECONDS) -> "InstanceLock":
|
|
179
|
+
"""Re-check, every ``interval`` seconds on a daemon thread, that the file this copy locked is still the file at
|
|
180
|
+
the path (FAA-303); the first time the path names another file (a swapped folder's lock, or the one a second
|
|
181
|
+
copy made), call ``on_lost(lock)`` once and stop watching. The default stops the process with 75 and one
|
|
182
|
+
sentence (``stop_lost_holder``): it runs on the watch thread, so it cannot raise into the service. A path that
|
|
183
|
+
is gone or cannot be read now is read as nothing there yet and tried again next time: a second copy has to make
|
|
184
|
+
a file there to start, and the watch then sees that file. ``release`` ends the watch. Windows does not rename a
|
|
185
|
+
folder holding an open file: there it is a no-op, as for a lock not held."""
|
|
186
|
+
if WINDOWS or self._fd is None or self._watch is not None:
|
|
187
|
+
return self
|
|
188
|
+
fd, stop, react = self._fd, threading.Event(), on_lost or stop_lost_holder
|
|
189
|
+
|
|
190
|
+
def run() -> None:
|
|
191
|
+
while not stop.wait(interval):
|
|
192
|
+
try:
|
|
193
|
+
here, held = os.stat(str(self.path)), os.fstat(fd)
|
|
194
|
+
except OSError:
|
|
195
|
+
continue
|
|
196
|
+
# release() sets the event before it closes the descriptor, so a number reused after the close is never
|
|
197
|
+
# read as this lock's file: the event is checked after the stat.
|
|
198
|
+
if (here.st_dev, here.st_ino) == (held.st_dev, held.st_ino) or stop.is_set():
|
|
199
|
+
continue
|
|
200
|
+
stop.set()
|
|
201
|
+
react(self)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
self._watch = stop
|
|
205
|
+
threading.Thread(target=run, name="fabric-lock-watch", daemon=True).start()
|
|
206
|
+
return self
|
|
207
|
+
|
|
160
208
|
def release(self) -> None:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if self.
|
|
165
|
-
|
|
166
|
-
self.
|
|
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."""
|
|
212
|
+
if self._watch is not None:
|
|
213
|
+
self._watch.set()
|
|
214
|
+
self._watch = 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
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def lost_lock_message(lock_path: Path) -> str:
|
|
230
|
+
"""The one sentence a holder whose lock moved under it stops with (FAA-303)."""
|
|
231
|
+
return ("%s is no longer the file this copy locked (the data folder moved or was swapped?): stopping, so a copy "
|
|
232
|
+
"started on the folder now at that path runs alone." % lock_path)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def stop_lost_holder(lock: InstanceLock) -> None:
|
|
236
|
+
"""``InstanceLock.watch``'s default: print ``lost_lock_message`` and end the process with 75 at once. ``os._exit``,
|
|
237
|
+
not a graceful stop: a shutdown would write this copy's state into the folder now at the path, which is not the
|
|
238
|
+
one it read; the kit's journals make an abrupt stop recoverable (LC-03)."""
|
|
239
|
+
try:
|
|
240
|
+
print(lost_lock_message(lock.path), file=sys.stderr, flush=True)
|
|
241
|
+
finally:
|
|
242
|
+
os._exit(EXIT_ALREADY_RUNNING)
|
|
167
243
|
|
|
168
244
|
|
|
169
245
|
def lock_name(lock_path: Path, platform: str = sys.platform) -> Optional[str]:
|
|
@@ -329,7 +405,8 @@ def add_supervisor_argument(parser: Any) -> Any:
|
|
|
329
405
|
def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *,
|
|
330
406
|
sleep: Callable[[float], None] = time.sleep,
|
|
331
407
|
clock: Callable[[], float] = time.monotonic,
|
|
332
|
-
env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None
|
|
408
|
+
env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None,
|
|
409
|
+
on_lost: Optional[Callable[[InstanceLock], None]] = None) -> InstanceLock:
|
|
333
410
|
"""Take the lock or exit 75 with one sentence naming the holder (DEC-0057, FAC-SEM-078).
|
|
334
411
|
|
|
335
412
|
Markers that disagree exit 75 at once, before the lock, naming them. Started by hand (no
|
|
@@ -337,7 +414,10 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
|
|
|
337
414
|
exit is a respawn every few seconds, forever; so a supervised copy first backs off in-process
|
|
338
415
|
(0.5 s doubling to 30 s, ``LOCK_WAIT_SUPERVISED_SECONDS`` in all), takes over if the holder
|
|
339
416
|
leaves, and exits 75 only when the wait runs out. Nothing has been touched while it waits.
|
|
340
|
-
``wait_seconds`` overrides the choice (0 = never wait); ``env`` and ``argv`` default to this process's.
|
|
417
|
+
``wait_seconds`` overrides the choice (0 = never wait); ``env`` and ``argv`` default to this process's.
|
|
418
|
+
|
|
419
|
+
Where the lock has no path-keyed name (macOS) the held lock is watched (``InstanceLock.watch``, FAA-303): a folder
|
|
420
|
+
swapped under the running copy stops it with 75; ``on_lost`` replaces that reaction."""
|
|
341
421
|
marker = supervisor_marker(env, argv)
|
|
342
422
|
if marker["kind"] == "conflict":
|
|
343
423
|
print(marker_conflict_message(marker), file=sys.stderr)
|
|
@@ -349,7 +429,7 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
|
|
|
349
429
|
announced = False
|
|
350
430
|
while True:
|
|
351
431
|
try:
|
|
352
|
-
|
|
432
|
+
lock = InstanceLock(data_dir).acquire()
|
|
353
433
|
except AlreadyRunning as exc:
|
|
354
434
|
remaining = deadline - clock()
|
|
355
435
|
if remaining <= 0:
|
|
@@ -360,3 +440,5 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
|
|
|
360
440
|
announced = True
|
|
361
441
|
sleep(min(delay, remaining))
|
|
362
442
|
delay = min(delay * 2, LOCK_BACKOFF_MAX_SECONDS)
|
|
443
|
+
continue
|
|
444
|
+
return lock.watch(on_lost) if lock_name(lock.path) is None else lock
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py
CHANGED
|
@@ -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
|