@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.0

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 (123) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +18 -6
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  106. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  107. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -18,6 +18,7 @@ Normative source: fabric-agent-contract docs/specification/interop.md (DEC-0016,
18
18
  from __future__ import annotations
19
19
 
20
20
  import base64
21
+ import hashlib
21
22
  import json
22
23
  from pathlib import Path
23
24
  import re
@@ -27,6 +28,7 @@ from typing import Any, Callable, Dict, List, Optional
27
28
 
28
29
  sys.path.insert(0, str(Path(__file__).resolve().parent))
29
30
  import fabric_service as _fs # noqa: E402
31
+ from fabric_base import store_lock # noqa: E402 - one lock per store path (R3)
30
32
 
31
33
  PROTOCOL = "fabric-interop/0.1"
32
34
  EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
@@ -49,11 +51,11 @@ JOB_HANDLE_SCHEMA: Dict[str, Any] = {
49
51
  }
50
52
  TERMINAL = ("completed", "failed", "cancelled")
51
53
 
52
- _TRACEPARENT = re.compile(r"^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$")
53
- _JOB_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
54
- _KEY = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
54
+ _TRACEPARENT = re.compile(r"^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})\Z")
55
+ _JOB_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}\Z")
56
+ _KEY = re.compile(r"^[A-Za-z0-9_.-]{1,64}\Z")
55
57
  _SECRET_WORDS = re.compile(r"pass(word|phrase)|secret|api[\s_-]?key|access[\s_-]?key|private[\s_-]?key|"
56
- r"(access|auth|bearer|refresh|session)[\s_-]?token|^token$|credential", re.IGNORECASE)
58
+ r"(access|auth|bearer|refresh|session)[\s_-]?token|^token\Z|credential", re.IGNORECASE)
57
59
 
58
60
 
59
61
  class InteropError(_fs.ServiceError):
@@ -255,12 +257,7 @@ def _own(value: Any, key: str) -> Any:
255
257
  return value.get(key, _ABSENT) if isinstance(value, dict) else _ABSENT
256
258
 
257
259
 
258
- def _days_from_civil(year: int, month: int, day: int) -> int:
259
- year -= month <= 2
260
- era = (year if year >= 0 else year - 399) // 400
261
- yoe = year - era * 400
262
- doy = (153 * (month + (-3 if month > 2 else 9)) + 2) // 5 + day - 1
263
- return era * 146097 + yoe * 365 + yoe // 4 - yoe // 100 + doy - 719468
260
+ from fabric_time import days_from_civil as _days_from_civil # noqa: E402 - the core's (FAA-65)
264
261
 
265
262
 
266
263
  def utc_time(value: Any) -> Optional[int]:
@@ -370,7 +367,6 @@ def intent_idempotency_key(intent: Any) -> Optional[str]:
370
367
  """The idempotency key of an intent {agentId, capability, input, request?}: a UUID of version 8 from the first 16
371
368
  bytes of the SHA-256 of its RFC 8785 form. The same intent gives the same key on every resend and machine; None
372
369
  for another shape or an input with no RFC 8785 form."""
373
- import hashlib
374
370
  import fabric_delegation as _fd # noqa: E402 - beside this file: RFC 8785
375
371
  names = ("agentId", "capability", "input", "request")
376
372
  if not isinstance(intent, dict) or any(name not in names for name in intent):
@@ -474,6 +470,16 @@ def pause_message(resets_at: Optional[str] = None, text: Optional[str] = None) -
474
470
  return message
475
471
 
476
472
 
473
+ def intent_digest(capability: str, arguments: Any) -> str:
474
+ """What a key is kept beside (FAA-77): the SHA-256 of the RFC 8785 form of [capability, input], so a key admits
475
+ only the intent it was first sent with. An input with no RFC 8785 form is refused."""
476
+ import fabric_delegation as _fd # noqa: E402 - beside this file: RFC 8785
477
+ try:
478
+ return "sha256:" + hashlib.sha256(_fd.jcs([capability, arguments]).encode("utf-8")).hexdigest()
479
+ except (TypeError, ValueError, RecursionError):
480
+ raise InteropError("A job's input with an idempotency key needs a JSON form (RFC 8785).") from None
481
+
482
+
477
483
  def repeated_key_decision(job: Any, now: Any, limit_holds: Optional[bool] = None, approval_granted: bool = False) -> str:
478
484
  """The provider's answer to a repeated key: "run" (the same intent runs again, once) or "same" (answer with that job).
479
485
  A quota-exhausted job runs again once the limit no longer holds: `limit_holds` False when the provider knows it, else
@@ -530,17 +536,17 @@ def url_request(message: str, url: str) -> Dict[str, Any]:
530
536
 
531
537
  # --- C3.2 jobs ---------------------------------------------------------------------
532
538
 
533
- class JobStore:
534
- """Durable jobs, one private JSON file each under `directory`, written atomically.
539
+ _SURROGATE = re.compile("[\ud800-\udfff]")
535
540
 
536
- A job id survives a restart of the agent, so fabric.job.get after a restart finds
537
- the same job. The file holds the public `job` object exactly as fabric.job.get
538
- returns it, and a `private` part (the capability, its input, the trace) that is
539
- never returned. The Node kit reads and writes the same format.
540
- """
541
+
542
+ class JobStore:
543
+ """Durable jobs, one private JSON file each under `directory`, written atomically and under the directory's lock.
544
+ A job id survives a restart. The file holds the public `job` object exactly as fabric.job.get returns it, and a
545
+ `private` part (the capability, its input, the trace) that is never returned; the Node kit uses the same format."""
541
546
 
542
547
  def __init__(self, directory: Path):
543
548
  self.dir = Path(directory)
549
+ self._lock = store_lock(self.dir)
544
550
 
545
551
  def _path(self, job_id: str) -> Path:
546
552
  if not _JOB_ID.match(job_id or ""):
@@ -567,48 +573,76 @@ class JobStore:
567
573
  limit_holds: Optional[bool] = None, approval_granted: bool = False) -> Dict[str, Any]:
568
574
  """Start a job; returns the handle a job-capable tool puts in structuredContent.
569
575
 
570
- With `idempotency_key` the key is kept per `caller` (the authenticated caller, DEC-0087): a key that already names
571
- a job starts nothing and raises InteropError, unless `repeat` would answer "run" (a limit no longer holds, an
572
- approval granted); then the key names the new job."""
573
- if idempotency_key is not None:
574
- decision = self.repeat(caller, idempotency_key, now if now is not None else _fs.now_iso(),
575
- limit_holds=limit_holds, approval_granted=approval_granted)
576
- if decision["decision"] == "same":
577
- raise InteropError("Key %s already names job %s; answer with that job (JobStore.repeat)." % (idempotency_key, decision["job"]["id"]))
578
- job_id = "job_" + secrets.token_hex(12)
579
- job: Dict[str, Any] = {"id": job_id, "status": "working"}
580
- if poll_interval_ms:
581
- job["pollIntervalMs"] = int(poll_interval_ms)
582
- private: Dict[str, Any] = {"capability": capability, "input": arguments,
583
- "traceparent": traceparent if parse_traceparent(traceparent) else None}
584
- if delegation: # DEC-0039: who started it; only they read, cancel or answer it
585
- private["delegation"] = {"principal": delegation.get("principal"), "caller": delegation.get("caller")}
586
- self._save({"job": job, "private": private})
587
- if idempotency_key is not None:
588
- _fs.atomic_write(self._key_path(caller, idempotency_key), (json.dumps({"job": job_id}) + "\n").encode(), 0o600)
589
- return {"job": {"id": job_id, "status": "working"}}
576
+ With `idempotency_key` the key is kept per `caller` (the authenticated caller, DEC-0087) beside the digest of the
577
+ intent (the capability and its input): a key that names a job of another intent raises `idempotency-conflict`;
578
+ one that names a job of this intent starts nothing and raises InteropError, unless `repeat` would answer "run"
579
+ (a limit no longer holds, an approval granted); then the key names the new job. A 0.40.0 key file: see `repeat`."""
580
+ with self._lock:
581
+ intent = None
582
+ if idempotency_key is not None:
583
+ intent = intent_digest(capability, arguments)
584
+ decision = self.repeat(caller, idempotency_key, now if now is not None else _fs.now_iso(), limit_holds=limit_holds,
585
+ approval_granted=approval_granted, capability=capability, arguments=arguments)
586
+ if decision["decision"] == "conflict":
587
+ raise InteropError("idempotency-conflict: key %s names a job of another intent; send the same capability and input, or a new key." % idempotency_key)
588
+ if decision["decision"] == "same":
589
+ raise InteropError("Key %s already names job %s; answer with that job (JobStore.repeat)." % (idempotency_key, decision["job"]["id"]))
590
+ job_id = "job_" + secrets.token_hex(12)
591
+ job: Dict[str, Any] = {"id": job_id, "status": "working"}
592
+ if poll_interval_ms:
593
+ job["pollIntervalMs"] = int(poll_interval_ms)
594
+ private: Dict[str, Any] = {"capability": capability, "input": arguments,
595
+ "traceparent": traceparent if parse_traceparent(traceparent) else None}
596
+ if delegation: # DEC-0039: who started it; only they read, cancel or answer it
597
+ private["delegation"] = {"principal": delegation.get("principal"), "caller": delegation.get("caller")}
598
+ self._save({"job": job, "private": private})
599
+ if idempotency_key is not None:
600
+ _fs.atomic_write(self._key_path(caller, idempotency_key), (json.dumps({"job": job_id, "intent": intent}) + "\n").encode(), 0o600)
601
+ return {"job": {"id": job_id, "status": "working"}}
590
602
 
591
603
  def _key_path(self, caller: Optional[str], key: str) -> Path:
592
- import hashlib
593
604
  if not isinstance(key, str) or not key:
594
605
  raise InteropError("An idempotency key is a non-empty string.")
606
+ if _SURROGATE.search(key) or (isinstance(caller, str) and _SURROGATE.search(caller)):
607
+ raise InteropError("An idempotency key and its caller are Unicode text; a lone surrogate is refused.")
595
608
  name = hashlib.sha256(json.dumps([caller, key], separators=(",", ":"), ensure_ascii=False).encode("utf-8")).hexdigest() # as JSON.stringify writes it
596
609
  directory = self.dir / "keys"
597
610
  _fs.ensure_private_dir(directory)
598
611
  return directory / ("%s.json" % name)
599
612
 
613
+ def _key_record(self, caller: Optional[str], key: str) -> Optional[Dict[str, Any]]:
614
+ try:
615
+ record = json.loads(self._key_path(caller, key).read_text(encoding="utf-8"))
616
+ except FileNotFoundError:
617
+ return None
618
+ except ValueError:
619
+ record = None
620
+ if not isinstance(record, dict):
621
+ raise InteropError("Key %s is unreadable on disk." % key)
622
+ return record
623
+
600
624
  def repeat(self, caller: Optional[str], key: str, now: Any, limit_holds: Optional[bool] = None,
601
- approval_granted: bool = False) -> Dict[str, Any]:
602
- """The answer to a key this caller sent before (DEC-0087): {"decision": "new"|"same"|"run", "job": job or None}.
603
- "same": answer with that job, never run it again (outcome-unknown above all); "run": the intent runs again, once."""
625
+ approval_granted: bool = False, *, capability: Optional[str] = None, arguments: Any = None) -> Dict[str, Any]:
626
+ """The answer to a key this caller sent before (DEC-0087): {"decision": "new"|"same"|"run"|"conflict", "job"}. "same": answer with
627
+ that job, never run it again (outcome-unknown above all); "run": the intent runs again, once; "conflict": given `capability` and
628
+ its input `arguments`, the key was kept beside another intent (FAA-77). A 0.40.0 key file ({"job": id}) never answers "conflict"."""
629
+ record = self._key_record(caller, key)
630
+ if record is None:
631
+ return {"decision": "new", "job": None}
604
632
  try:
605
- job_id = json.loads(self._key_path(caller, key).read_text(encoding="utf-8"))["job"]
606
- job = self.get(job_id)
607
- except (FileNotFoundError, UnknownJob):
633
+ job = self.get(record["job"])
634
+ except UnknownJob:
608
635
  return {"decision": "new", "job": None}
609
- except (ValueError, KeyError, TypeError):
636
+ except (KeyError, TypeError):
610
637
  raise InteropError("Key %s is unreadable on disk." % key) from None
611
- return {"decision": repeated_key_decision(job, now, limit_holds, approval_granted), "job": job}
638
+ decision = repeated_key_decision(job, now, limit_holds, approval_granted)
639
+ if capability is not None:
640
+ intent = intent_digest(capability, arguments)
641
+ if "intent" in record:
642
+ decision = "conflict" if record["intent"] != intent else decision
643
+ elif decision == "run": # a 0.40.0 key runs only the intent its job stored (an input with no JSON form is refused)
644
+ decision = "run" if intent_digest(*map(self.private(record["job"]).get, ("capability", "input"))) == intent else "same"
645
+ return {"decision": decision, "job": job}
612
646
 
613
647
  def get(self, job_id: str) -> Dict[str, Any]:
614
648
  return dict(self._load(job_id)["job"])
@@ -620,15 +654,16 @@ class JobStore:
620
654
  return self._load(job_id)["private"].get("traceparent")
621
655
 
622
656
  def _transition(self, job_id: str, status: str, **fields: Any) -> Dict[str, Any]:
623
- record = self._load(job_id)
624
- job = record["job"]
625
- if job["status"] in TERMINAL:
626
- raise InteropError("Job %s is already %s; a terminal job does not change." % (job_id, job["status"]))
627
- for key in ("inputRequests", "result", "error", "statusMessage"):
628
- job.pop(key, None)
629
- job["status"] = status
630
- job.update({key: value for key, value in fields.items() if value is not None})
631
- return self._save(record)
657
+ with self._lock:
658
+ record = self._load(job_id)
659
+ job = record["job"]
660
+ if job["status"] in TERMINAL:
661
+ raise InteropError("Job %s is already %s; a terminal job does not change." % (job_id, job["status"]))
662
+ for key in ("inputRequests", "result", "error", "statusMessage"):
663
+ job.pop(key, None)
664
+ job["status"] = status
665
+ job.update({key: value for key, value in fields.items() if value is not None})
666
+ return self._save(record)
632
667
 
633
668
  def working(self, job_id: str, message: Optional[str] = None) -> Dict[str, Any]:
634
669
  return self._transition(job_id, "working", statusMessage=message)
@@ -646,15 +681,16 @@ class JobStore:
646
681
  """Apply inputResponses to an input_required job; unknown keys are ignored (MCP Tasks rule).
647
682
 
648
683
  Returns the responses that matched; when any did, the job is working again."""
649
- job = self.get(job_id)
650
- if job["status"] != "input_required":
651
- return {}
652
- matched = {key: value for key, value in (input_responses or {}).items()
653
- if key in job.get("inputRequests", {}) and isinstance(value, dict)
654
- and value.get("action") in ("accept", "decline", "cancel")}
655
- if matched:
656
- self.working(job_id)
657
- return matched
684
+ with self._lock:
685
+ job = self.get(job_id)
686
+ if job["status"] != "input_required":
687
+ return {}
688
+ matched = {key: value for key, value in (input_responses or {}).items()
689
+ if key in job.get("inputRequests", {}) and isinstance(value, dict)
690
+ and value.get("action") in ("accept", "decline", "cancel")}
691
+ if matched:
692
+ self.working(job_id)
693
+ return matched
658
694
 
659
695
  def complete(self, job_id: str, envelope: Dict[str, Any]) -> Dict[str, Any]:
660
696
  """The envelope carries the job's trace, authoritative for the stored result (DEC-0017); one that names
@@ -680,13 +716,10 @@ class JobStore:
680
716
  """A job a limit paused and its provider will resume (DEC-0087): working, its statusMessage in the pause grammar,
681
717
  and pollIntervalMs reaching the reset within its cap of one hour."""
682
718
  message = pause_message(resets_at, text)
683
- record = self._load(job_id)
684
- if resets_at is not None:
685
- wait = utc_time(resets_at) - utc_time(_fs.now_iso())
686
- if wait > 0:
687
- record["job"]["pollIntervalMs"] = int(min(wait, 3600000))
688
- self._save(record)
689
- return self._transition(job_id, "working", statusMessage=message)
719
+ wait = utc_time(resets_at) - utc_time(_fs.now_iso()) if resets_at is not None else 0
720
+ # One write, after the terminal check: a terminal job does not change, pollIntervalMs included (finding 11).
721
+ return self._transition(job_id, "working", statusMessage=message,
722
+ pollIntervalMs=int(min(wait, 3600000)) if wait > 0 else None)
690
723
 
691
724
  def quota_exhausted(self, job_id: str, message: str, retryable: bool, resets_at: Optional[str] = None) -> Dict[str, Any]:
692
725
  """A job a limit stopped and its provider will not resume: failed, quota-exhausted, data {retryable, resetsAt?}."""
@@ -791,7 +824,8 @@ def mcp_header_problem(message: Any, headers: Any) -> Optional[str]:
791
824
  return "Mcp-Method %s disagrees with the body's method %s" % (sent, method)
792
825
  field = NAMED_METHODS.get(str(method))
793
826
  if field:
794
- value = (message.get("params") or {}).get(field)
827
+ params = message.get("params")
828
+ value = params.get(field) if isinstance(params, dict) else None
795
829
  named = header("Mcp-Name")
796
830
  if named is None and declared:
797
831
  return "the body carries params.%s but the required Mcp-Name header is absent" % field
@@ -878,8 +912,11 @@ class McpToolServer:
878
912
  if "id" not in message:
879
913
  return None # a notification gets no response
880
914
  rid = message["id"]
881
- params = message.get("params") or {}
882
- meta = params.get("_meta") or {}
915
+ params = {} if message.get("params") is None else message["params"]
916
+ meta = params.get("_meta") if isinstance(params, dict) else None
917
+ meta = {} if meta is None else meta
918
+ if not isinstance(params, dict) or not isinstance(meta, dict): # finding 17: answered, never a crash
919
+ return self._error(rid, -32602, "params and params._meta are JSON objects.")
883
920
  span = child_traceparent(meta.get("traceparent"))
884
921
  method = message["method"]
885
922
  if method == "initialize":
@@ -898,7 +935,9 @@ class McpToolServer:
898
935
  if method != "tools/call":
899
936
  return self._error(rid, -32601, "Method %s is not served here." % method)
900
937
  name = params.get("name")
901
- arguments = params.get("arguments") or {}
938
+ arguments = {} if params.get("arguments") is None else params["arguments"]
939
+ if not isinstance(name, str) or not isinstance(arguments, dict):
940
+ return self._error(rid, -32602, "A tool call names its tool as a string and passes its arguments as a JSON object.")
902
941
  if name in ("fabric.job.get", "fabric.job.cancel") and self.jobs is not None:
903
942
  verdict = None
904
943
  if self._authorize is not None:
@@ -941,7 +980,10 @@ class McpToolServer:
941
980
  if name == "fabric.job.cancel":
942
981
  state = self.jobs.get(job_id)
943
982
  if state["status"] not in TERMINAL:
944
- state = self.jobs.cancel(job_id)
983
+ try:
984
+ state = self.jobs.cancel(job_id)
985
+ except InteropError: # it ended meanwhile: answer as it ended
986
+ state = self.jobs.get(job_id)
945
987
  return tool_result({"job": state}, traceparent=job_span)
946
988
  responses = arguments.get("inputResponses")
947
989
  if responses:
@@ -0,0 +1,310 @@
1
+ """JavaScript's view of JSON values, once for the whole kit (FAA-65, layer L0).
2
+
3
+ The contract's answers are computed by TypeScript, so a kit that reads JSON the way Python does disagrees with them on
4
+ the edges: `5.0` is a count, an integer beyond a double is Infinity, `String({"toString": 1})` throws, object keys
5
+ that look like array indexes come first. Every reader in the kit takes those semantics from here:
6
+
7
+ - ``js_string`` (``String()``), ``show`` (the contract's ``show``: ``String()``, or JSON where it would throw),
8
+ ``json_stringify`` (``JSON.stringify``), ``js_to_number`` (``Number()``), ``double``, ``safe_integer``;
9
+ - ``same`` (``===``) and ``contains``, ``get`` with the ``MISSING`` sentinel for an absent member;
10
+ - ``jcs`` (RFC 8785 canonical JSON) and the number formatting it shares with ``js_string``.
11
+
12
+ Standard library only; nothing here imports another kit module.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ import re
18
+ from typing import Any, Dict, List, Tuple
19
+
20
+
21
+ def js_number_string(value: float) -> str:
22
+ """ECMAScript Number::toString, which RFC 8785 §3.2.2.3 prescribes."""
23
+ if value == 0:
24
+ return "0"
25
+ if value < 0:
26
+ return "-" + js_number_string(-value)
27
+ digits, exponent = _shortest(value)
28
+ k, n = len(digits), len(digits) + exponent
29
+ if k <= n <= 21:
30
+ return digits + "0" * (n - k)
31
+ if 0 < n <= 21:
32
+ return digits[:n] + "." + digits[n:]
33
+ if -6 < n <= 0:
34
+ return "0." + "0" * (-n) + digits
35
+ e = n - 1
36
+ mantissa = digits if k == 1 else digits[0] + "." + digits[1:]
37
+ return "%se%s%d" % (mantissa, "+" if e >= 0 else "-", abs(e))
38
+
39
+
40
+ def _shortest(value: float) -> Tuple[str, int]:
41
+ """The shortest round-trip digits of a positive finite double (Python's repr is shortest round-trip)."""
42
+ text = repr(value)
43
+ mantissa, _, exp = text.partition("e")
44
+ whole, _, frac = mantissa.partition(".")
45
+ digits = (whole + frac).lstrip("0")
46
+ exponent = (int(exp) if exp else 0) - len(frac)
47
+ if not digits:
48
+ return "0", 0
49
+ stripped = digits.rstrip("0")
50
+ exponent += len(digits) - len(stripped)
51
+ return stripped, exponent
52
+
53
+
54
+ JSON_ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
55
+
56
+
57
+ def json_string_literal(value: str) -> str:
58
+ out = ['"']
59
+ for ch in value:
60
+ code = ord(ch)
61
+ if 0xD800 <= code <= 0xDFFF:
62
+ raise TypeError("a lone surrogate has no canonical form")
63
+ if ch in JSON_ESCAPES:
64
+ out.append(JSON_ESCAPES[ch])
65
+ elif code < 0x20:
66
+ out.append("\\u%04x" % code)
67
+ else:
68
+ out.append(ch)
69
+ out.append('"')
70
+ return "".join(out)
71
+
72
+
73
+ def jcs(value: Any) -> str:
74
+ """RFC 8785: members sorted by UTF-16 code units, numbers in ECMAScript form, strings as JSON.stringify
75
+ escapes them. Refuses what has no canonical form: a non-finite number, a lone surrogate, a non-JSON value.
76
+ A JSON number is a double: an integer beyond 2**53 is written as JavaScript would write it."""
77
+ if value is None:
78
+ return "null"
79
+ if value is True:
80
+ return "true"
81
+ if value is False:
82
+ return "false"
83
+ if isinstance(value, int):
84
+ if abs(value) < 2 ** 53:
85
+ return str(value)
86
+ try:
87
+ return js_number_string(float(value))
88
+ except OverflowError:
89
+ raise TypeError("a number beyond a double has no canonical form") from None
90
+ if isinstance(value, float):
91
+ if not math.isfinite(value):
92
+ raise TypeError("a non-finite number has no canonical form")
93
+ return js_number_string(value)
94
+ if isinstance(value, str):
95
+ return json_string_literal(value)
96
+ if isinstance(value, list):
97
+ return "[" + ",".join(jcs(item) for item in value) + "]"
98
+ if isinstance(value, dict):
99
+ if not all(isinstance(key, str) for key in value):
100
+ raise TypeError("only string member names have a canonical form")
101
+ keys = sorted(value, key=lambda key: key.encode("utf-16-be", "surrogatepass"))
102
+ return "{" + ",".join(json_string_literal(key) + ":" + jcs(value[key]) for key in keys) + "}"
103
+ raise TypeError("%s has no canonical form" % type(value).__name__)
104
+
105
+
106
+ MISSING = object()
107
+
108
+
109
+ def js_string(value: Any) -> str:
110
+ """String(value) for a JSON value."""
111
+ if value is MISSING:
112
+ return "undefined"
113
+ if value is None:
114
+ return "null"
115
+ if value is True:
116
+ return "true"
117
+ if value is False:
118
+ return "false"
119
+ if isinstance(value, float):
120
+ if math.isnan(value):
121
+ return "NaN"
122
+ if math.isinf(value):
123
+ return "Infinity" if value > 0 else "-Infinity"
124
+ return js_number_string(value)
125
+ if isinstance(value, int):
126
+ if abs(value) < 2 ** 53:
127
+ return str(value)
128
+ try:
129
+ return js_number_string(float(value))
130
+ except OverflowError: # a JSON number beyond a double is Infinity in JavaScript
131
+ return "Infinity" if value > 0 else "-Infinity"
132
+ if isinstance(value, dict):
133
+ return "[object Object]"
134
+ if isinstance(value, list):
135
+ return ",".join("" if item is None else js_string(item) for item in value)
136
+ return str(value)
137
+
138
+
139
+ def get(value: Any, key: str) -> Any:
140
+ return value.get(key, MISSING) if isinstance(value, dict) else MISSING
141
+
142
+
143
+ def get_string(value: Any, key: str) -> str:
144
+ return js_string(get(value, key))
145
+
146
+
147
+ def is_number(value: Any) -> bool:
148
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
149
+
150
+
151
+ def double(value: Any) -> float:
152
+ """A JSON number as JavaScript holds it: the nearest double, and an integer beyond the double range ±Infinity."""
153
+ try:
154
+ return float(value)
155
+ except OverflowError:
156
+ return math.inf if value > 0 else -math.inf
157
+
158
+
159
+ def same(a: Any, b: Any) -> bool:
160
+ """JavaScript's === (and a Set's SameValueZero) on parsed JSON: an object equals only itself."""
161
+ if a is MISSING or b is MISSING:
162
+ return a is b
163
+ if isinstance(a, (dict, list)) or isinstance(b, (dict, list)):
164
+ return a is b
165
+ if isinstance(a, bool) or isinstance(b, bool):
166
+ return isinstance(a, bool) and isinstance(b, bool) and a == b
167
+ if is_number(a) and is_number(b):
168
+ return double(a) == double(b) # 2**53 + 1 is 2**53 in JavaScript
169
+ return type(a) is type(b) and a == b
170
+
171
+
172
+ def contains(items: List[Any], value: Any) -> bool:
173
+ return any(same(item, value) for item in items)
174
+
175
+
176
+ #: The characters JavaScript's StringToNumber trims: WhiteSpace and LineTerminator (ECMA-262 §12.2, §12.3).
177
+ _JS_SPACE = "\t\n\v\f\r              

   "
178
+
179
+
180
+ _JS_DECIMAL = re.compile(r"[+-]?(?:(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?|Infinity)", re.ASCII)
181
+
182
+
183
+ _JS_RADIX = re.compile(r"0(?:[xX](?P<x>[0-9a-fA-F]+)|[oO](?P<o>[0-7]+)|[bB](?P<b>[01]+))", re.ASCII)
184
+
185
+
186
+ def js_to_number(value: Any) -> float:
187
+ """Number(value) for a JSON value, as JavaScript reads it; NaN for what it cannot read as a number."""
188
+ if value is MISSING:
189
+ return math.nan
190
+ if value is None:
191
+ return 0.0
192
+ if isinstance(value, bool):
193
+ return 1.0 if value else 0.0
194
+ if is_number(value):
195
+ try:
196
+ return float(value)
197
+ except OverflowError:
198
+ return math.inf if value > 0 else -math.inf
199
+ if isinstance(value, list):
200
+ return js_to_number(js_string(value)) # Number([2]) is Number("2"); Number([]) is 0
201
+ if isinstance(value, str):
202
+ text = value.strip(_JS_SPACE)
203
+ if text == "":
204
+ return 0.0
205
+ if _JS_DECIMAL.fullmatch(text):
206
+ return float(text.replace("Infinity", "inf"))
207
+ radix = _JS_RADIX.fullmatch(text)
208
+ if radix:
209
+ digits, base = next((radix.group(k), b) for k, b in (("x", 16), ("o", 8), ("b", 2)) if radix.group(k))
210
+ return double(int(digits, base))
211
+ return math.nan
212
+
213
+
214
+ def safe_integer(value: Any) -> bool:
215
+ if isinstance(value, int) and not isinstance(value, bool):
216
+ return abs(value) <= 2 ** 53 - 1 # checked before any float: a 400-digit integer would overflow
217
+ return isinstance(value, float) and math.isfinite(value) and value.is_integer() and abs(value) <= 2 ** 53 - 1
218
+
219
+
220
+ def objects(value: Any) -> List[Dict[str, Any]]:
221
+ return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
222
+
223
+
224
+ def indexed_objects(value: Any) -> List[Tuple[Dict[str, Any], int]]:
225
+ """The object items of an array with their index in the array as written, so an instancePath names the item it
226
+ flags even when a non-object precedes it (the contract's ``indexed``)."""
227
+ return [(item, i) for i, item in enumerate(value) if isinstance(item, dict)] if isinstance(value, list) else []
228
+
229
+
230
+ class Unshowable(Exception):
231
+ pass
232
+
233
+
234
+ def strict_string(value: Any) -> str:
235
+ """String(value), raising where JavaScript's String() throws: an object whose own ``toString`` is not a function
236
+ (every own member of parsed JSON is data), directly or inside an array it joins."""
237
+ if isinstance(value, dict):
238
+ if "toString" in value:
239
+ raise Unshowable()
240
+ return "[object Object]"
241
+ if isinstance(value, list):
242
+ return ",".join("" if item is None else strict_string(item) for item in value)
243
+ return js_string(value)
244
+
245
+
246
+ _INDEX_KEY = re.compile(r"0|[1-9][0-9]*", re.ASCII)
247
+
248
+
249
+ def js_keys(value: Dict[str, Any]) -> List[str]:
250
+ """An object's own keys in the order JavaScript lists them: array-index keys ("0" … "4294967294", no leading zero)
251
+ ascending, then the others in the order written."""
252
+ index = sorted((k for k in value if _INDEX_KEY.fullmatch(k) and int(k) <= 2 ** 32 - 2), key=int)
253
+ taken = set(index)
254
+ return index + [k for k in value if k not in taken]
255
+
256
+
257
+ def json_string(text: str) -> str:
258
+ """JSON.stringify of a string (well-formed, ES2019): a lone surrogate is escaped as \\uXXXX, never refused."""
259
+ out = ['"']
260
+ for ch in text:
261
+ code = ord(ch)
262
+ if ch in JSON_ESCAPES:
263
+ out.append(JSON_ESCAPES[ch])
264
+ elif code < 0x20 or 0xD800 <= code <= 0xDFFF:
265
+ out.append("\\u%04x" % code)
266
+ else:
267
+ out.append(ch)
268
+ out.append('"')
269
+ return "".join(out)
270
+
271
+
272
+ def json_stringify(value: Any) -> str:
273
+ """JSON.stringify for a parsed JSON value: members in JavaScript's key order, numbers as JavaScript writes them, a
274
+ lone surrogate escaped."""
275
+ if isinstance(value, dict):
276
+ return "{" + ",".join(json_string(k) + ":" + json_stringify(value[k]) for k in js_keys(value)) + "}"
277
+ if isinstance(value, list):
278
+ return "[" + ",".join(json_stringify(v) for v in value) + "]"
279
+ if value is None:
280
+ return "null"
281
+ if isinstance(value, bool):
282
+ return "true" if value else "false"
283
+ if is_number(value):
284
+ text = js_string(value)
285
+ return "null" if text in ("NaN", "Infinity", "-Infinity") else text
286
+ return json_string(value)
287
+
288
+
289
+ def show(value: Any) -> str:
290
+ """The contract's ``show``: String(value), or the value's JSON where String() would throw."""
291
+ try:
292
+ return strict_string(value)
293
+ except Unshowable:
294
+ return json_stringify(value)
295
+
296
+
297
+ def key_of(*parts: Any) -> str:
298
+ """The contract's ``keyOf``: a collision-free key over JSON values, JSON.stringify of the parts (an absent value
299
+ is null inside an array, as JavaScript writes it)."""
300
+ return json_stringify([None if part is MISSING else part for part in parts])
301
+ from typing import Any
302
+
303
+
304
+ def json_equal(a: Any, b: Any) -> bool:
305
+ """The contract's jsonEqual: arrays in order, objects by their members, primitives as JavaScript's ===."""
306
+ if isinstance(a, list) or isinstance(b, list):
307
+ return isinstance(a, list) and isinstance(b, list) and len(a) == len(b) and all(json_equal(x, y) for x, y in zip(a, b))
308
+ if isinstance(a, dict) or isinstance(b, dict):
309
+ return isinstance(a, dict) and isinstance(b, dict) and len(a) == len(b) and all(k in b and json_equal(a[k], b[k]) for k in a)
310
+ return same(a, b)