@passioncode-ai/fabric-agent-adapter 0.78.0 → 0.79.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 +122 -0
- package/README.md +14 -7
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +37 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +2 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +14 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
- 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 +34 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +8 -5
- 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 +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +37 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +6 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +12 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +7 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +17 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +13 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +14 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +215 -25
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +10 -5
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +37 -5
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +6 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +4 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +10 -9
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +57 -37
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +50 -25
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +26 -10
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +11 -5
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py
CHANGED
|
@@ -20,7 +20,7 @@ HOLD_RETRY_MAX_SECONDS = 900.0
|
|
|
20
20
|
#: Refusals that are about the board session, not one capability: they end recovery and go to the responder's fault.
|
|
21
21
|
SESSION_CODES = frozenset(["unreachable", "session-ended", "not_authorized", "authority_changed", "credential", "no-epoch",
|
|
22
22
|
"schema-unsupported", "checkpoint", "receipt-shape", "idempotency_conflict", "not-running",
|
|
23
|
-
"not-a-hub", "hub-unavailable", "protocol", "not_available"])
|
|
23
|
+
"not-a-hub", "hub-unavailable", "protocol", "not_available", "foreign-user"])
|
|
24
24
|
|
|
25
25
|
_whole = lambda v: int(v) if isinstance(v, float) and v.is_integer() else v # noqa: E731 - 2.0 is 2, as Node reads it
|
|
26
26
|
|
|
@@ -78,7 +78,7 @@ class SlotKeeper:
|
|
|
78
78
|
if _is_int(generation, 1) and (answer.get("repeated") is True or not moved):
|
|
79
79
|
rest = {"project": self.project, "generation": generation}
|
|
80
80
|
except BoardError as exc:
|
|
81
|
-
if exc.code in ("unreachable", "session-ended"):
|
|
81
|
+
if exc.code in ("unreachable", "session-ended", "foreign-user"): # foreign-user: the earlier ask is unknown
|
|
82
82
|
raise
|
|
83
83
|
if exc.code == "idempotency_window_expired":
|
|
84
84
|
rest = {} # a retired epoch refuses even a repeat: unknown, so no record (a first start, never a move)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py
CHANGED
|
@@ -45,6 +45,7 @@ from fabric_jsvalue import ( # noqa: E402 - the core (FAA-65)
|
|
|
45
45
|
jcs,
|
|
46
46
|
)
|
|
47
47
|
from fabric_paths import atomic_write as _atomic_write # noqa: E402 - the kit's private writer (FAA-85)
|
|
48
|
+
import fabric_peer_owner as _po # noqa: E402 - a loopback key set is read from this account's listener only (FAA-255)
|
|
48
49
|
from fabric_mandate import effect_closure # noqa: E402,F401 - defined once beside the mandate that reads it too (DEC-0125)
|
|
49
50
|
|
|
50
51
|
REFUSAL_CODES = (
|
|
@@ -672,9 +673,21 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
|
672
673
|
return None
|
|
673
674
|
|
|
674
675
|
|
|
675
|
-
|
|
676
|
+
class _CheckedHTTPConnection(_po.CheckedHTTPConnection):
|
|
677
|
+
"""Plain http, which a key set source allows on loopback only (DEC-0154 HA-2, FAA-255): every connect, a reconnect
|
|
678
|
+
included, tells the server end's owner and refuses (closed, nothing sent) unless it is this OS account's process,
|
|
679
|
+
so keys another account's listener serves are never read; built the way urllib builds a connection."""
|
|
680
|
+
|
|
681
|
+
def __init__(self, host: str, port: Optional[int] = None, timeout: Any = 10.0, **kwargs: Any) -> None:
|
|
682
|
+
http.client.HTTPConnection.__init__(self, host, port, timeout=timeout, **kwargs)
|
|
683
|
+
self.expect_pid = None
|
|
684
|
+
self.vouch_pid = None # an issuer's URL is never a process this caller started (FAA-267)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _opener(deadline: Dict[str, Any], direct: bool = False) -> urllib.request.OpenerDirector:
|
|
676
688
|
"""No redirect, and a timer that shuts the socket down at the deadline: a socket timeout bounds one read, so an
|
|
677
|
-
issuer trickling a byte a second would otherwise hold the caller for as long as it likes (finding 13).
|
|
689
|
+
issuer trickling a byte a second would otherwise hold the caller for as long as it likes (finding 13). `direct`:
|
|
690
|
+
a loopback source never goes through a proxy from the environment, so the owner check reads the URL's own port."""
|
|
678
691
|
def watched(base: type) -> type:
|
|
679
692
|
class Watched(base): # type: ignore[misc, valid-type]
|
|
680
693
|
def connect(self) -> None:
|
|
@@ -695,11 +708,13 @@ def _opener(deadline: Dict[str, Any]) -> urllib.request.OpenerDirector:
|
|
|
695
708
|
|
|
696
709
|
class Http(urllib.request.HTTPHandler):
|
|
697
710
|
def http_open(self, req: Any) -> Any:
|
|
698
|
-
return self.do_open(watched(
|
|
711
|
+
return self.do_open(watched(_CheckedHTTPConnection), req) # http is loopback only (the source check)
|
|
699
712
|
|
|
700
713
|
class Https(urllib.request.HTTPSHandler):
|
|
701
714
|
def https_open(self, req: Any) -> Any:
|
|
702
715
|
return self.do_open(watched(http.client.HTTPSConnection), req, context=self._context)
|
|
716
|
+
if direct:
|
|
717
|
+
return urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect, Http, Https)
|
|
703
718
|
return urllib.request.build_opener(_NoRedirect, Http, Https)
|
|
704
719
|
|
|
705
720
|
|
|
@@ -709,7 +724,8 @@ def _fetch(source: str) -> Any:
|
|
|
709
724
|
request = urllib.request.Request(source.partition("#")[0], headers={"Accept": "application/json"})
|
|
710
725
|
deadline: Dict[str, Any] = {"end": time.monotonic() + JWKS_FETCH_SECONDS, "fired": False, "timer": None}
|
|
711
726
|
try:
|
|
712
|
-
|
|
727
|
+
direct = source.lower().startswith("http://")
|
|
728
|
+
with _opener(deadline, direct).open(request, timeout=JWKS_FETCH_SECONDS) as response: # https or loopback http only
|
|
713
729
|
body = b""
|
|
714
730
|
while len(body) <= 65536 and not deadline["fired"]:
|
|
715
731
|
chunk = response.read1(65537 - len(body))
|
|
@@ -724,6 +740,8 @@ def _fetch(source: str) -> Any:
|
|
|
724
740
|
raise
|
|
725
741
|
except http.client.HTTPException as exc: # a garbage status line or headers: the issuer cannot be read
|
|
726
742
|
raise ConnectionError(type(exc).__name__) from None
|
|
743
|
+
except _po.ListenerRefused as exc: # FAA-255: another account's listener, or one that cannot be told
|
|
744
|
+
raise ConnectionError("foreign-user: %s" % exc) from None
|
|
727
745
|
finally:
|
|
728
746
|
if deadline["timer"] is not None:
|
|
729
747
|
deadline["timer"].cancel()
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py
CHANGED
|
@@ -18,6 +18,7 @@ from pathlib import Path as _Path # noqa: E402
|
|
|
18
18
|
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
19
19
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
20
20
|
from fabric_base import (
|
|
21
|
+
PRIMARY_CAPABILITY,
|
|
21
22
|
PROTOCOL,
|
|
22
23
|
ServiceError,
|
|
23
24
|
_ID,
|
|
@@ -49,7 +50,7 @@ from fabric_usage import (
|
|
|
49
50
|
DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
|
|
50
51
|
|
|
51
52
|
|
|
52
|
-
DASHBOARD_KIT = "fabric-dashboard-kit@1.1.
|
|
53
|
+
DASHBOARD_KIT = "fabric-dashboard-kit@1.1.6"
|
|
53
54
|
|
|
54
55
|
|
|
55
56
|
REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
|
|
@@ -628,6 +629,11 @@ def configuration_problems(doc: Any) -> List[str]:
|
|
|
628
629
|
return problems
|
|
629
630
|
|
|
630
631
|
|
|
632
|
+
#: FAA-244: DEC-0140 PR-1 reserves the primary responder's capability; a service never publishes it.
|
|
633
|
+
_RESERVED = ("%%s: %s is reserved for a Project's primary responder, a session (DEC-0140 PR-1); a service never "
|
|
634
|
+
"serves it as a tool or declares it." % PRIMARY_CAPABILITY)
|
|
635
|
+
|
|
636
|
+
|
|
631
637
|
def build_well_known(*, service_id: str, instance: str, name: str, version: str, build: Dict[str, Any],
|
|
632
638
|
started_at: str, status: str, degraded: Sequence[Dict[str, str]],
|
|
633
639
|
surfaces: Dict[str, Any], summary: Optional[Sequence[Dict[str, Any]]] = None,
|
|
@@ -664,6 +670,10 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
664
670
|
problems = a2a_surface_problems(surfaces["a2a"])
|
|
665
671
|
if problems:
|
|
666
672
|
raise ServiceError("surfaces.a2a: %s." % "; ".join(problems))
|
|
673
|
+
mcp_surface = surfaces.get("mcp")
|
|
674
|
+
listed = mcp_surface.get("capabilities") if isinstance(mcp_surface, dict) else None
|
|
675
|
+
if isinstance(listed, list) and PRIMARY_CAPABILITY in listed:
|
|
676
|
+
raise ServiceError(_RESERVED % "surfaces.mcp.capabilities")
|
|
667
677
|
doc: Dict[str, Any] = {
|
|
668
678
|
"protocol": PROTOCOL,
|
|
669
679
|
"service": {"id": service_id, "instance": instance, "name": name, "version": version, "build": build},
|
|
@@ -698,6 +708,9 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
698
708
|
"surfaces": surfaces})
|
|
699
709
|
if findings:
|
|
700
710
|
raise ServiceError("Actions declaration: %s." % "; ".join(f["message"] for f in findings))
|
|
711
|
+
if any(isinstance(a, dict) and a.get("tool") == PRIMARY_CAPABILITY
|
|
712
|
+
for a in extensions[ACTIONS_EXTENSION_KEY].get("actions") or []):
|
|
713
|
+
raise ServiceError(_RESERVED % "an action's tool")
|
|
701
714
|
doc["extensions"] = dict(extensions)
|
|
702
715
|
if configuration is not None:
|
|
703
716
|
problems = configuration_problems(configuration)
|
|
@@ -73,8 +73,42 @@ class InstanceLock:
|
|
|
73
73
|
#: a file is allowed there).
|
|
74
74
|
WINDOWS_LOCK_OFFSET = 1 << 30
|
|
75
75
|
|
|
76
|
+
#: How many times acquire re-opens service.lock when the file it locked is no longer the file at its path (a
|
|
77
|
+
#: folder swapped under it by a data import), before it gives up with an error naming the path (FAA-293).
|
|
78
|
+
INODE_RETRIES = 3
|
|
79
|
+
|
|
76
80
|
def acquire(self) -> "InstanceLock":
|
|
77
81
|
ensure_private_dir(self.path.parent)
|
|
82
|
+
for _ in range(self.INODE_RETRIES):
|
|
83
|
+
if self._acquire_once():
|
|
84
|
+
return self
|
|
85
|
+
raise ServiceError("%s kept moving while it was locked (a folder swapped under the data folder?): no copy "
|
|
86
|
+
"holds a file that is no longer at its path; start again once the folder is settled." % self.path)
|
|
87
|
+
|
|
88
|
+
def _locked_file_is_at_path(self, fd: int) -> bool:
|
|
89
|
+
"""The inode this copy locked is the file at the path now (FAA-293). Windows does not rename a folder holding
|
|
90
|
+
an open file, and its lock is a byte range on this handle, so the check is POSIX only."""
|
|
91
|
+
if WINDOWS:
|
|
92
|
+
return True
|
|
93
|
+
try:
|
|
94
|
+
here, held = os.stat(str(self.path)), os.fstat(fd)
|
|
95
|
+
except FileNotFoundError:
|
|
96
|
+
return False
|
|
97
|
+
return (here.st_dev, here.st_ino) == (held.st_dev, held.st_ino)
|
|
98
|
+
|
|
99
|
+
def _let_go(self, fd: int) -> None:
|
|
100
|
+
try:
|
|
101
|
+
os.close(fd)
|
|
102
|
+
except OSError:
|
|
103
|
+
pass
|
|
104
|
+
name, self._name = self._name, None
|
|
105
|
+
if name is not None:
|
|
106
|
+
try:
|
|
107
|
+
_drop_lock_name(name)
|
|
108
|
+
except OSError:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
def _acquire_once(self) -> bool:
|
|
78
112
|
fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT | _O_BINARY, 0o600)
|
|
79
113
|
try:
|
|
80
114
|
if WINDOWS:
|
|
@@ -92,6 +126,9 @@ class InstanceLock:
|
|
|
92
126
|
if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES, getattr(errno, "EDEADLOCK", -1)):
|
|
93
127
|
raise AlreadyRunning(_read_pid(self.path), self.path) from None
|
|
94
128
|
raise
|
|
129
|
+
if not self._locked_file_is_at_path(fd): # another copy may hold the file at the path: re-open that one
|
|
130
|
+
os.close(fd)
|
|
131
|
+
return False
|
|
95
132
|
try:
|
|
96
133
|
self._name = _hold_lock_name(self.path)
|
|
97
134
|
except BaseException:
|
|
@@ -114,8 +151,11 @@ class InstanceLock:
|
|
|
114
151
|
except OSError: # as with the close: nothing to add to the first error
|
|
115
152
|
pass
|
|
116
153
|
raise
|
|
154
|
+
if not self._locked_file_is_at_path(fd): # swapped while the pid was written: this copy holds nothing real
|
|
155
|
+
self._let_go(fd)
|
|
156
|
+
return False
|
|
117
157
|
self._fd = fd
|
|
118
|
-
return
|
|
158
|
+
return True
|
|
119
159
|
|
|
120
160
|
def release(self) -> None:
|
|
121
161
|
if self._fd is not None:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py
CHANGED
|
@@ -366,6 +366,10 @@ class Client:
|
|
|
366
366
|
if answer is not None:
|
|
367
367
|
answer["attempts"] = attempts
|
|
368
368
|
return answer
|
|
369
|
+
if outcome == "foreign-user": # FAA-257: the fallback would go to the same refused listener
|
|
370
|
+
raise OpenRouterError("The base URL's port is held by another OS account's process, or its owner "
|
|
371
|
+
"cannot be told; nothing was sent (DEC-0154 HA-2)", kind="foreign-user",
|
|
372
|
+
attempts=attempts)
|
|
369
373
|
if outcome in _STOP_AT_ONCE.values():
|
|
370
374
|
raise OpenRouterError("OpenRouter refused the key or the request (%s)" % status, kind=outcome,
|
|
371
375
|
attempts=attempts)
|
|
@@ -390,6 +394,8 @@ class Client:
|
|
|
390
394
|
# the provider may have billed a call whose answer never arrived whole (a cut connection looks the
|
|
391
395
|
# same as a refused one from here): counted, never as $0; over-counting is the safe side
|
|
392
396
|
self.ledger.record(self._unmeasured(asked, task_run_id, worst))
|
|
397
|
+
if err.kind == "foreign-user": # nothing was sent: nothing billed, and no other model there (FAA-257)
|
|
398
|
+
return "foreign-user", err.status, None, None
|
|
393
399
|
return ("retry" if err.kind in ("connection", "timeout") else "refused"), err.status, None, None
|
|
394
400
|
status = response.status
|
|
395
401
|
if status in _STOP_AT_ONCE:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -22,6 +22,7 @@ from fabric_time import DAY_MS as _DAY_MS, utc_date_ms as _utc_date_ms # noqa:
|
|
|
22
22
|
from fabric_base import ( # noqa: F401
|
|
23
23
|
PROTOCOL,
|
|
24
24
|
EXTENSION_KEY,
|
|
25
|
+
PRIMARY_CAPABILITY,
|
|
25
26
|
SUPERVISOR_ENV,
|
|
26
27
|
DRAIN_SECONDS,
|
|
27
28
|
_ID,
|
|
@@ -319,6 +320,9 @@ from fabric_supervisors import ( # noqa: F401
|
|
|
319
320
|
launchd_loaded,
|
|
320
321
|
launchd_pid,
|
|
321
322
|
fetch_well_known,
|
|
323
|
+
well_known_of,
|
|
324
|
+
job_well_known_of,
|
|
325
|
+
ForeignUserAnswer,
|
|
322
326
|
_OVERRIDE_ROW,
|
|
323
327
|
launchd_override,
|
|
324
328
|
launchd_install,
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py
CHANGED
|
@@ -4,6 +4,7 @@ removed without touching what the operator turned off; release pruning (LC-15).
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import http.client
|
|
7
|
+
import ipaddress
|
|
7
8
|
import json
|
|
8
9
|
import os
|
|
9
10
|
import plistlib
|
|
@@ -383,6 +384,29 @@ def launchd_pid(label: str, domain: Optional[str] = None) -> Optional[int]:
|
|
|
383
384
|
return None
|
|
384
385
|
|
|
385
386
|
|
|
387
|
+
def _loopback_host(host: Optional[str]) -> bool:
|
|
388
|
+
"""Whether `host` (urlsplit's hostname: lower-case, no brackets) names this machine: localhost, 127/8 or ::1."""
|
|
389
|
+
if not host:
|
|
390
|
+
return False
|
|
391
|
+
if host == "localhost":
|
|
392
|
+
return True
|
|
393
|
+
try:
|
|
394
|
+
return ipaddress.ip_address(host).is_loopback
|
|
395
|
+
except ValueError:
|
|
396
|
+
return False
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class ForeignUserAnswer:
|
|
400
|
+
"""A refused listener (DEC-0154 HA-2), as `well_known_of` answers it: never a document, whatever keys a service's JSON
|
|
401
|
+
carries. `reason` starts with `foreign-user` or `table-restricted` (the Node twin's class, FAA-259)."""
|
|
402
|
+
|
|
403
|
+
def __init__(self, reason: str) -> None:
|
|
404
|
+
self.reason = reason
|
|
405
|
+
|
|
406
|
+
def __repr__(self) -> str:
|
|
407
|
+
return "ForeignUserAnswer(%r)" % self.reason
|
|
408
|
+
|
|
409
|
+
|
|
386
410
|
def _read_well_known(origin: str, timeout: float = 2.0,
|
|
387
411
|
vouch_pid: Optional[int] = None) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
388
412
|
"""(the well-known answer or None, why the listener was refused or None). A loopback origin is read only on a
|
|
@@ -391,10 +415,16 @@ def _read_well_known(origin: str, timeout: float = 2.0,
|
|
|
391
415
|
table hides other processes. `vouch_pid`, the pid of a launchd job this
|
|
392
416
|
caller started, proves the listener when the socket table hides other processes (FAA-267)."""
|
|
393
417
|
parts = urllib.parse.urlsplit(origin)
|
|
394
|
-
|
|
395
|
-
|
|
418
|
+
try:
|
|
419
|
+
port = parts.port or (80 if parts.scheme == "http" else None) # no port is http's 80, as in the Node twin
|
|
420
|
+
except ValueError: # a port out of range
|
|
421
|
+
port = None
|
|
422
|
+
if parts.scheme == "http" and port and _loopback_host(parts.hostname):
|
|
423
|
+
# every loopback spelling (127.0.0.1, another 127/8 address, localhost, [::1]) goes on a checked connection,
|
|
424
|
+
# never the unchecked way (FAA-257)
|
|
425
|
+
conn = _po.CheckedHTTPConnection(parts.hostname, port, timeout=timeout, vouch_pid=vouch_pid)
|
|
396
426
|
try:
|
|
397
|
-
conn.request("GET", "/.well-known/fabric-service", headers={"Host":
|
|
427
|
+
conn.request("GET", "/.well-known/fabric-service", headers={"Host": parts.netloc.rpartition("@")[2]})
|
|
398
428
|
response = conn.getresponse()
|
|
399
429
|
return (json.loads(response.read().decode()) if response.status == 200 else None), None
|
|
400
430
|
except _po.ListenerRefused as exc:
|
|
@@ -514,10 +544,24 @@ def _read_job_well_known(origin: str, job_pid: int, job_uid: Optional[int] = Non
|
|
|
514
544
|
|
|
515
545
|
def fetch_well_known(origin: str, timeout: float = 2.0) -> Optional[Dict[str, Any]]:
|
|
516
546
|
"""The well-known answer at `origin`, or None: nothing answers, the answer is not JSON, or the listener is not this
|
|
517
|
-
OS account's process (DEC-0154 HA-2)."""
|
|
547
|
+
OS account's process (DEC-0154 HA-2). `well_known_of` tells the last two apart."""
|
|
518
548
|
return _read_well_known(origin, timeout)[0]
|
|
519
549
|
|
|
520
550
|
|
|
551
|
+
def well_known_of(origin: str, timeout: float = 2.0, vouch_pid: Optional[int] = None) -> Any:
|
|
552
|
+
"""The well-known answer at `origin` as the Node `wellKnownOf` gives it (FAA-259): the document; None when nothing
|
|
553
|
+
answers or the answer is not JSON (down); a `ForeignUserAnswer` when the listener is another OS account's process or
|
|
554
|
+
its owner cannot be told. So a status reads HA-2's order: down, foreign-user, foreign, status."""
|
|
555
|
+
doc, why = _read_well_known(origin, timeout, vouch_pid=vouch_pid)
|
|
556
|
+
return ForeignUserAnswer(why) if why else doc
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def job_well_known_of(origin: str, job_pid: int, job_uid: Optional[int] = None, timeout: float = 2.0) -> Any:
|
|
560
|
+
"""As `well_known_of`, for a LaunchDaemon whose job `job_pid` (running as `job_uid`) must hold the listener."""
|
|
561
|
+
doc, why = _read_job_well_known(origin, job_pid, job_uid)
|
|
562
|
+
return ForeignUserAnswer(why) if why else doc
|
|
563
|
+
|
|
564
|
+
|
|
521
565
|
_OVERRIDE_ROW = re.compile(r'^\s*"([^"]+)"\s*=>\s*(\S+)\s*\Z')
|
|
522
566
|
|
|
523
567
|
|
|
@@ -593,7 +637,9 @@ def _refuse_held_label(label: str, plist_path: Path) -> None:
|
|
|
593
637
|
|
|
594
638
|
def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin: str,
|
|
595
639
|
service_id: str, instance: str = "default", timeout: float = 40.0,
|
|
596
|
-
force_enable: bool = False, domain: Optional[str] = None
|
|
640
|
+
force_enable: bool = False, domain: Optional[str] = None,
|
|
641
|
+
fetch_well_known: Optional[Callable[..., Any]] = None,
|
|
642
|
+
fetch_job_well_known: Optional[Callable[..., Any]] = None) -> Dict[str, Any]:
|
|
597
643
|
"""Write + lint the plist, bootout and WAIT for unload, bootstrap (retry EIO 5), then
|
|
598
644
|
poll the well-known document until it answers with this identity.
|
|
599
645
|
|
|
@@ -606,7 +652,13 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
606
652
|
A plist in ``/Library/LaunchDaemons`` (or ``domain="system"``) is a LaunchDaemon in launchd's system domain, started
|
|
607
653
|
at boot with no login (FAA-278): only root installs it, refused before anything is written otherwise, and the
|
|
608
654
|
answer counts only when launchd's own job accepted the connection (its ``UserName``, not the installer, owns it).
|
|
609
|
-
A ``gui/<uid>`` bootstrap with no GUI session (SSH, a headless Mac after a FileVault restart) says so.
|
|
655
|
+
A ``gui/<uid>`` bootstrap with no GUI session (SSH, a headless Mac after a FileVault restart) says so.
|
|
656
|
+
|
|
657
|
+
``fetch_well_known(origin, vouch_pid=...)`` and ``fetch_job_well_known(origin, job_pid, job_uid)`` read the answer as
|
|
658
|
+
``well_known_of`` and ``job_well_known_of`` do (the document, None, or a ``ForeignUserAnswer``); a caller may inject
|
|
659
|
+
its own, as the Node ``launchdInstall`` takes ``fetchWellKnown`` (FAA-259)."""
|
|
660
|
+
read_answer = fetch_well_known or (lambda origin_, vouch_pid=None: well_known_of(origin_, vouch_pid=vouch_pid))
|
|
661
|
+
read_job = fetch_job_well_known or job_well_known_of
|
|
610
662
|
plist_path = Path(plist_path)
|
|
611
663
|
dom = launchd_domain(plist_path, domain)
|
|
612
664
|
which = "system" if dom == "system" else None
|
|
@@ -685,9 +737,10 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
685
737
|
foreign = None
|
|
686
738
|
while time.time() < deadline:
|
|
687
739
|
job = launchd_pid(label, "system")
|
|
688
|
-
doc
|
|
740
|
+
doc = read_job(origin, job, job_uid) if job else None
|
|
741
|
+
why = doc.reason if isinstance(doc, ForeignUserAnswer) else None
|
|
689
742
|
foreign = why or foreign # the last refusal stays the reason while launchd shows no pid
|
|
690
|
-
if _answers(doc, service_id, instance):
|
|
743
|
+
if not why and _answers(doc, service_id, instance):
|
|
691
744
|
return doc
|
|
692
745
|
time.sleep(0.5)
|
|
693
746
|
loaded = ("; the job stays loaded and its plist starts it again at boot: as root, `launchctl bootout system/%s` "
|
|
@@ -702,10 +755,11 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
702
755
|
% (origin, service_id, instance, timeout, loaded))
|
|
703
756
|
foreign = vouch = None
|
|
704
757
|
while time.time() < deadline:
|
|
705
|
-
doc
|
|
758
|
+
doc = read_answer(origin, vouch_pid=vouch)
|
|
759
|
+
foreign = doc.reason if isinstance(doc, ForeignUserAnswer) else None
|
|
706
760
|
if foreign and foreign.startswith(_po.TABLE_RESTRICTED):
|
|
707
761
|
vouch = launchd_pid(label) # only a restricted table needs the job's pid (FAA-267)
|
|
708
|
-
if _answers(doc, service_id, instance):
|
|
762
|
+
if not foreign and _answers(doc, service_id, instance):
|
|
709
763
|
return doc
|
|
710
764
|
time.sleep(0.5)
|
|
711
765
|
if foreign:
|