@passioncode-ai/fabric-agent-adapter 0.80.2 → 0.81.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.
- package/CHANGELOG.md +79 -0
- package/README.md +13 -2
- package/SECURITY.md +23 -8
- package/bin/fabric-agent-adapter.js +24 -1
- 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 +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +8 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +107 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +14 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +18 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +55 -75
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +912 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +5 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +8 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +25 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +22 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +17 -28
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +135 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +78 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +266 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +10 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +76 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +85 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +316 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +31 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +13 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +18 -3
- 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_supervisors.py +64 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +141 -17
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +8 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +14 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
|
@@ -361,13 +361,43 @@ class _CheckedHTTPConnection(_po.CheckedHTTPConnection):
|
|
|
361
361
|
self.vouch_pid = None # a loopback URL is never a process this caller started (FAA-267)
|
|
362
362
|
|
|
363
363
|
|
|
364
|
+
def _peer_is_loopback(sock: Any) -> bool:
|
|
365
|
+
"""Whether a connected socket reached this computer's loopback, by the address it connected to, not by the URL's
|
|
366
|
+
spelling: a name the hosts file maps to 127.0.0.1, `0.0.0.0`, `::ffff:127.0.0.1` and a proxy on loopback all
|
|
367
|
+
arrive here (0.74.0 unit readings 7, 8 and 11; FAA-260)."""
|
|
368
|
+
try:
|
|
369
|
+
host = str(sock.getpeername()[0]).split("%", 1)[0]
|
|
370
|
+
address = ipaddress.ip_address(host)
|
|
371
|
+
except (OSError, ValueError, TypeError, IndexError, AttributeError):
|
|
372
|
+
return False
|
|
373
|
+
mapped = getattr(address, "ipv4_mapped", None)
|
|
374
|
+
return bool((mapped or address).is_loopback or address.is_unspecified)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class _PlainHTTPConnection(http.client.HTTPConnection):
|
|
378
|
+
"""Plain http to a host not spelled as loopback (`insecure=True`) or to a proxy from the environment: when the
|
|
379
|
+
connected socket's peer is a loopback address, its server end is told as `_CheckedHTTPConnection` tells it, and
|
|
380
|
+
another account's, root's or an unknown owner is refused before a byte is sent (DEC-0154 HA-2, FAA-260)."""
|
|
381
|
+
|
|
382
|
+
def connect(self) -> None:
|
|
383
|
+
super().connect()
|
|
384
|
+
if not _peer_is_loopback(self.sock):
|
|
385
|
+
return
|
|
386
|
+
owner = _po.server_end(self.sock)
|
|
387
|
+
port = self.sock.getpeername()[1]
|
|
388
|
+
refused = _po.refusal(owner, port, None)
|
|
389
|
+
if refused is not None:
|
|
390
|
+
self.close()
|
|
391
|
+
raise _po.ListenerRefused(refused[0], "%s; nothing was sent" % refused[1], owner)
|
|
392
|
+
|
|
393
|
+
|
|
364
394
|
def _opener(watchdog: _Watchdog, direct: bool = False) -> urllib.request.OpenerDirector:
|
|
365
395
|
"""`direct`: a loopback URL never goes through a proxy from the environment (`http_proxy`), so the connection the
|
|
366
396
|
owner check reads is the one to the URL's own port (DEC-0154 HA-2), and a bearer never reaches a proxy."""
|
|
367
397
|
class Http(urllib.request.HTTPHandler):
|
|
368
398
|
def http_open(self, req):
|
|
369
399
|
local = _is_loopback((urllib.parse.urlsplit(req.full_url).hostname or "").lower())
|
|
370
|
-
return self.do_open(_watched(_CheckedHTTPConnection if local else
|
|
400
|
+
return self.do_open(_watched(_CheckedHTTPConnection if local else _PlainHTTPConnection, watchdog), req)
|
|
371
401
|
|
|
372
402
|
class Https(urllib.request.HTTPSHandler):
|
|
373
403
|
def https_open(self, req):
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py
CHANGED
|
@@ -700,8 +700,76 @@ def compose(draft: Dict[str, Any], files: Dict[str, bytes]) -> Tuple[Dict[str, A
|
|
|
700
700
|
return pkg, notes
|
|
701
701
|
|
|
702
702
|
|
|
703
|
+
#: The agent's source manifest (contract DEC-0149, ``fabric-source/1``): when the folder carries one, ``pack`` writes
|
|
704
|
+
#: ``secrets[]`` from its secret slots and ``state[]`` from its store slots' paths, and FAC-SEM-160 holds them equal.
|
|
705
|
+
SOURCE_FILE = "fabric.source.json"
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _source_manifest(files: Dict[str, bytes]) -> Optional[Dict[str, Any]]:
|
|
709
|
+
"""The folder's source manifest, or None when it has none; a refusal when it is no ``fabric-source/1`` object."""
|
|
710
|
+
if SOURCE_FILE not in files:
|
|
711
|
+
return None
|
|
712
|
+
source = _load_json(files[SOURCE_FILE], SOURCE_FILE)
|
|
713
|
+
if source.get("format") != "fabric-source/1":
|
|
714
|
+
raise PackageError("%s is not a fabric-source/1 manifest (its format member)." % SOURCE_FILE)
|
|
715
|
+
return source
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def source_members(source: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
719
|
+
"""``secrets[]`` from the secret slots (name, purpose, optional) and ``state[]`` from the store slots (path, purpose)."""
|
|
720
|
+
secrets: List[Dict[str, Any]] = []
|
|
721
|
+
state: List[Dict[str, Any]] = []
|
|
722
|
+
for slot in _objects(source.get("slots")):
|
|
723
|
+
if slot.get("kind") == "secret" and isinstance(slot.get("name"), str):
|
|
724
|
+
secrets.append(dict({"name": slot["name"], "purpose": slot.get("purpose")}, **({"optional": True} if slot.get("optional") is True else {})))
|
|
725
|
+
elif slot.get("kind") == "store" and isinstance(slot.get("path"), str):
|
|
726
|
+
state.append({"path": slot["path"], "purpose": slot.get("purpose")})
|
|
727
|
+
return secrets, state
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def source_package_findings(source: Any, pkg: Any) -> List[Dict[str, str]]:
|
|
731
|
+
"""FAC-SEM-160 (contract ``sourcePackageFindings``): the package's secrets[] are the source's secret slots, by name
|
|
732
|
+
and optional; its state[] paths are the store slots' paths when state[] holds any entry."""
|
|
733
|
+
if not isinstance(source, dict) or not isinstance(pkg, dict):
|
|
734
|
+
return []
|
|
735
|
+
out: List[Dict[str, str]] = []
|
|
736
|
+
|
|
737
|
+
def flag(at: str, message: str) -> None:
|
|
738
|
+
out.append({"code": "FAC-SEM-160", "instancePath": at, "message": message})
|
|
739
|
+
slots = _objects(source.get("slots"))
|
|
740
|
+
secrets = {s["name"]: s.get("optional") is True for s in slots if s.get("kind") == "secret" and isinstance(s.get("name"), str)}
|
|
741
|
+
stores = list(dict.fromkeys(s["path"] for s in slots if s.get("kind") == "store" and isinstance(s.get("path"), str)))
|
|
742
|
+
packaged = [(e, i) for i, e in enumerate(pkg.get("secrets") if isinstance(pkg.get("secrets"), list) else []) if isinstance(e, dict)]
|
|
743
|
+
names = {e.get("name") for e, _ in packaged}
|
|
744
|
+
for name in secrets:
|
|
745
|
+
if name not in names:
|
|
746
|
+
flag("/package/secrets", "the source's secret slot %s is not in the package's secrets[]" % name)
|
|
747
|
+
for entry, index in packaged:
|
|
748
|
+
name = entry.get("name")
|
|
749
|
+
if not isinstance(name, str) or name not in secrets:
|
|
750
|
+
flag("/package/secrets/%d/name" % index, "secrets[%d] is no secret slot of the source" % index)
|
|
751
|
+
elif secrets[name] != (entry.get("optional") is True):
|
|
752
|
+
flag("/package/secrets/%d/optional" % index, "secrets[%d] is %soptional in the source" % (index, "" if secrets[name] else "not "))
|
|
753
|
+
state = pkg.get("state")
|
|
754
|
+
if not isinstance(state, list) or not state:
|
|
755
|
+
return out
|
|
756
|
+
paths = {e.get("path") for e in _objects(state)}
|
|
757
|
+
for path in stores:
|
|
758
|
+
if path not in paths:
|
|
759
|
+
flag("/package/state", "a store slot's path of the source is not in the package's state[]")
|
|
760
|
+
for index, entry in ((i, e) for i, e in enumerate(state) if isinstance(e, dict)):
|
|
761
|
+
if not isinstance(entry.get("path"), str) or entry["path"] not in stores:
|
|
762
|
+
flag("/package/state/%d/path" % index, "state[%d] is no store slot of the source" % index)
|
|
763
|
+
return out
|
|
764
|
+
|
|
765
|
+
|
|
703
766
|
def _all_problems(pkg: Dict[str, Any], files: Dict[str, bytes]) -> List[Dict[str, str]]:
|
|
704
767
|
problems = [dict(p, rule="schema") for p in schema_problems(pkg)]
|
|
768
|
+
if SOURCE_FILE in files:
|
|
769
|
+
try:
|
|
770
|
+
problems += [dict(p, rule=p["code"]) for p in source_package_findings(_source_manifest(files), pkg)]
|
|
771
|
+
except PackageError as exc:
|
|
772
|
+
problems.append({"rule": "source", "instancePath": "/files", "message": str(exc)})
|
|
705
773
|
problems += [dict(p, rule="path") for p in path_problems(pkg)]
|
|
706
774
|
problems += [dict(p, rule=p["code"]) for p in package_findings(pkg)]
|
|
707
775
|
problems += [{"rule": "folder", "problem": p["problem"], "instancePath": p["path"], "message": FOLDER_MESSAGES.get(p["problem"], p["problem"])}
|
|
@@ -716,6 +784,24 @@ FOLDER_MESSAGES = {
|
|
|
716
784
|
}
|
|
717
785
|
|
|
718
786
|
|
|
787
|
+
def _secrets_lacking(draft: Dict[str, Any], secrets: List[Dict[str, Any]]) -> List[str]:
|
|
788
|
+
"""The names a hand-written secrets[] declares that the source manifest has no secret slot for, in their order."""
|
|
789
|
+
have = {s["name"] for s in secrets}
|
|
790
|
+
declared = draft.get("secrets") if isinstance(draft.get("secrets"), list) else []
|
|
791
|
+
lacking: List[str] = []
|
|
792
|
+
for entry in declared:
|
|
793
|
+
name = entry.get("name") if isinstance(entry, dict) else None
|
|
794
|
+
if isinstance(name, str) and name not in have and name not in lacking:
|
|
795
|
+
lacking.append(name)
|
|
796
|
+
return lacking
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _lacking_message(lacking: List[str]) -> str:
|
|
800
|
+
return ("%s declares no secret slot for %s, which %s's secrets[] names: pack writes secrets[] from the manifest's "
|
|
801
|
+
"slots and never drops a declared secret. Add each as a slot of kind secret to %s (or take it out of "
|
|
802
|
+
"secrets[]), then pack again" % (SOURCE_FILE, ", ".join(lacking), PACKAGE_FILE, SOURCE_FILE))
|
|
803
|
+
|
|
804
|
+
|
|
719
805
|
def pack(folder: Any, apply: bool = False) -> Dict[str, Any]:
|
|
720
806
|
"""Pack ``folder`` (see the module's doc). The answer names the digest, ``agentRef`` and every problem; with
|
|
721
807
|
``apply`` and no problem, ``fabric.package.json`` is rewritten."""
|
|
@@ -725,8 +811,24 @@ def pack(folder: Any, apply: bool = False) -> Dict[str, Any]:
|
|
|
725
811
|
raise PackageError("Write %s in the folder first: schema, kind, id, version, publisher, licence and, for an agent, "
|
|
726
812
|
"resultSignal and evals with its receipt. pack fills in agentRef, files[] and digest." % PACKAGE_FILE)
|
|
727
813
|
draft = _load_json(files[PACKAGE_FILE], PACKAGE_FILE)
|
|
814
|
+
source = _source_manifest(files)
|
|
815
|
+
written: List[str] = []
|
|
816
|
+
refused: List[Dict[str, str]] = []
|
|
817
|
+
if source is not None: # DEC-0149: secrets[] and state[] come from the source manifest's slots
|
|
818
|
+
secrets, state = source_members(source)
|
|
819
|
+
lacking = _secrets_lacking(draft, secrets)
|
|
820
|
+
if lacking: # a declared secret is never dropped: the manifest gains its slot first
|
|
821
|
+
refused.append({"rule": "source", "instancePath": "/secrets", "message": _lacking_message(lacking)})
|
|
822
|
+
elif secrets or "secrets" in draft:
|
|
823
|
+
draft["secrets"] = secrets
|
|
824
|
+
written.append("secrets[]")
|
|
825
|
+
if state and draft.get("state") != []: # an empty state[] is a copy packed without state, kept
|
|
826
|
+
draft["state"] = state
|
|
827
|
+
written.append("state[]")
|
|
728
828
|
pkg, notes = compose(draft, files)
|
|
729
|
-
|
|
829
|
+
if written:
|
|
830
|
+
notes.append("%s written from %s" % (" and ".join(written), SOURCE_FILE))
|
|
831
|
+
problems = refused + _all_problems(pkg, files)
|
|
730
832
|
suite = pkg.get("evals", {}).get("suite") if isinstance(pkg.get("evals"), dict) else None
|
|
731
833
|
if isinstance(suite, str) and suite in files and any(p.get("instancePath") == "/evals/receipt/suite" for p in problems):
|
|
732
834
|
notes.append("the suite packed is %s: run it again and write that receipt" % sha256_of(files[suite]))
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"""peer_owner: which OS account owns each end of a loopback TCP connection.
|
|
2
2
|
|
|
3
|
-
peer_owner 1.
|
|
4
|
-
DEC-0154 HA-2, FAA-226) from Project Observatory's `observatory/engine/peer_owner.py
|
|
5
|
-
commit f86f0622 (passioncode-ai/project-observatory-dashboard, the
|
|
3
|
+
peer_owner 1.2.0+kit.5: Observatory's peer_owner 1.2.0, vendored into the Fabric service kit as `fabric_peer_owner` (layer L0;
|
|
4
|
+
DEC-0154 HA-2, FAA-226) from Project Observatory's `observatory/engine/peer_owner.py`, first at
|
|
5
|
+
commit f86f0622 (1.1.0) and read against commit 2180235c (1.2.0, passioncode-ai/project-observatory-dashboard, the
|
|
6
|
+
same licence): the kit's line names 1.2.0 since kit.5 (FAA-258), whose Linux rule kit.1 to kit.4 already carried
|
|
7
|
+
and the contract's CT-254 states (v0.1.0+2026-11-07.3). Observatory's 1.3.x (this account's own sockets through
|
|
8
|
+
libproc, the contract's DEC-0155 RT-2) is not in it yet (FAA-292). It differs
|
|
6
9
|
from that file in this paragraph, in `Owner` and `Row` (NamedTuples here where it has
|
|
7
10
|
frozen dataclasses: a dataclass with postponed annotations needs its module in sys.modules,
|
|
8
11
|
and a service may load a kit file by its path without one), and in writing `Optional[X]`
|
|
@@ -11,7 +14,8 @@
|
|
|
11
14
|
SID before the system's (a client running as root or SYSTEM owns root's listener), and in
|
|
12
15
|
two Linux and selection rules: on Linux `server_end` reads an accepted connection's own row
|
|
13
16
|
and, for one not accept()ed yet (printed with uid 0 and inode 0), the listener row on its
|
|
14
|
-
local address (state 0A;
|
|
17
|
+
local address (state 0A; 1.2.0's rule, where the kit names its reasons `not-accepted`, `several-rows` and `closed`
|
|
18
|
+
and Observatory `no-listener`, `listener-no-socket`, `listeners-disagree` and `no-socket`), `peer` reads a client
|
|
15
19
|
row with inode 0 (a closed client) as unknown, and several rows of one owner are that owner;
|
|
16
20
|
a row printed twice by a `/proc` read under churn counts once, and a `/proc` question that
|
|
17
21
|
finds no row or several reads the table once more; ctypes is imported where it is used, so a
|
|
@@ -111,7 +115,7 @@ import time
|
|
|
111
115
|
from typing import NamedTuple, Optional, Tuple
|
|
112
116
|
|
|
113
117
|
# This file's own version, for projects that copy it; not the engine's (that is configuration.VERSION).
|
|
114
|
-
PEER_OWNER_VERSION = "1.
|
|
118
|
+
PEER_OWNER_VERSION = "1.2.0+kit.5" # Observatory's 1.2.0 with the kit's differences above
|
|
115
119
|
|
|
116
120
|
THIS_ACCOUNT = "this-account"
|
|
117
121
|
ANOTHER_ACCOUNT = "another-account"
|
|
@@ -122,7 +126,8 @@ KINDS = (THIS_ACCOUNT, ANOTHER_ACCOUNT, SYSTEM, UNKNOWN, NONE)
|
|
|
122
126
|
|
|
123
127
|
#: The most table bytes one read accepts. The spec of this module asked for 1 MB; measured,
|
|
124
128
|
#: `pcblist_n` costs about 860 bytes a socket, so 1 MB would hold about 1,200 sockets and a busy
|
|
125
|
-
#: developer machine passes that. 4 MiB holds about
|
|
129
|
+
#: developer machine passes that. 4 MiB holds about 6,800 at the 616 bytes a socket measured on macOS 26
|
|
130
|
+
#: (0.74.0 product reading 11, FAA-260).
|
|
126
131
|
MAX_TABLE_BYTES = 4 * 1024 * 1024
|
|
127
132
|
#: Every subprocess this module starts is stopped after this many seconds.
|
|
128
133
|
TIMEOUT_SECONDS = 2.0
|
|
@@ -804,7 +809,7 @@ def _owner_of(local: tuple[str, int], remote: tuple[str, int], server: bool = Fa
|
|
|
804
809
|
# (inet_csk_listen_stop), so that listener answers. An accepted connection's own row carries the uid of the
|
|
805
810
|
# process that accepted it (sock_graft): it answers itself, so a listener swapped after another account
|
|
806
811
|
# accepted the client cannot pass (Project Observatory's 1.2.0 reading, board thread ced00f6e; the contract's
|
|
807
|
-
#
|
|
812
|
+
# CT-254, HA-2's Linux source, released in v0.1.0+2026-11-07.3).
|
|
808
813
|
# A server row its owner already closed (an orphan) also has inode 0 and reads so: no process holds it, so
|
|
809
814
|
# nothing sent to it reaches anyone (0.74.0 unit reading 6).
|
|
810
815
|
return _one_owner(_listeners_of(rows, found[0].local), how, "not-accepted")
|
|
@@ -856,7 +861,7 @@ def server_end(sock: socket.socket) -> Owner:
|
|
|
856
861
|
server's and whose remote address is this socket's own, which is the server process's
|
|
857
862
|
accepted socket, so a listener swapped after `listen` cannot pass for the one that answers.
|
|
858
863
|
On Linux a connection not yet accept()ed has no owner of its own (inode 0): its listener
|
|
859
|
-
answers (the contract's
|
|
864
|
+
answers (the contract's CT-254, HA-2's Linux source)."""
|
|
860
865
|
try:
|
|
861
866
|
own, server = sock.getsockname(), sock.getpeername()
|
|
862
867
|
except OSError as exc:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py
CHANGED
|
@@ -20,7 +20,7 @@ PROTOCOL_REVISIONS: Dict[str, Tuple[str, ...]] = {
|
|
|
20
20
|
"fabric-operator-channel": ("0.1",), "fabric-package": ("1",),
|
|
21
21
|
"fabric-peer-share": ("1",), "fabric-peer-share-envelope": ("1",), "fabric-peer-share-report": ("1",), "fabric-project-comms": ("0.1",), "fabric-proposal": ("1",),
|
|
22
22
|
"fabric-provider": ("0.1",), "fabric-relay": ("0.1",), "fabric-secret-envelope": ("1",), "fabric-service": ("0.1",),
|
|
23
|
-
"fabric-settings-backup": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "machine-report": ("1",), "mandate": ("1",), "memory": ("0.1",), "node-summary": ("1",),
|
|
23
|
+
"fabric-settings-backup": ("1",), "fabric-source": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "machine-report": ("1",), "mandate": ("1",), "memory": ("0.1",), "node-summary": ("1",),
|
|
24
24
|
"operator-source": ("1",), "org-adaptation": ("1",), "org-adaptation-receipt": ("1",), "org-authority": ("1",), "org-authority-draft": ("1",), "org-key-set": ("1",), "org-key-set-draft": ("1",),
|
|
25
25
|
"org-membership": ("1",), "org-root-registration": ("1",), "pairing-message": ("1",),
|
|
26
26
|
"peer-share-deletion": ("1",), "peer-share-frame": ("1",), "peer-share-revocation": ("1",),
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py
CHANGED
|
@@ -30,8 +30,20 @@ CREDENTIAL_PAIRS: Dict[str, tuple] = {
|
|
|
30
30
|
JS_SPACE = "\t\n\x0b\x0c\r \xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
|
|
31
31
|
_NOT_LINE_END = "[^\n\r\u2028\u2029]" # ECMAScript's `.` without the s flag
|
|
32
32
|
JWT_SHAPE = re.compile(r"eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*")
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
#: Where a provider key's prefix may start (DEC-0151): the start of a value, a character that is no ASCII letter or
|
|
34
|
+
#: digit, or an escape (a percent-escape, single or double, a backslash escape, an octal, `\\x` with two hex digits,
|
|
35
|
+
#: `\\u` with four, an ESC colour sequence, raw or escaped). A consuming group, as the contract writes it.
|
|
36
|
+
_KEY_BOUNDARY = (r"(?:^|[^A-Za-z0-9]|%[0-9A-Fa-f]{2}|\\[nrt\"']|\\u[0-9A-Fa-f]{4}|\\x[0-9A-Fa-f]{2}|\\[bfv0ae]|\\[0-7]{1,3}"
|
|
37
|
+
r"|%25[0-9A-Fa-f]{2}|\x1b\[[0-9;:]*m|(?:\\u001[bB]|\\x1[bB]|\\e|\\033|\\33)\[[0-9;:]*m)")
|
|
38
|
+
#: The provider keys' shapes, prefixes in their canonical case only (DEC-0151 item 7: `SK-` or `GHP_` is no key). An
|
|
39
|
+
#: `sk-` key holds, within 256 characters, a digit and an upper-case letter with 16 or more characters, or a digit with
|
|
40
|
+
#: 32 or more; the look-aheads are bounded to 255 characters and lazy, so a long run of prefixes reads in linear time.
|
|
41
|
+
_PROVIDER_KEYS = (r"(?:sk-(?=[A-Za-z0-9_-]{0,255}?[0-9])(?:(?=[A-Za-z0-9_-]{0,255}?[A-Z])[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{32,})"
|
|
42
|
+
r"|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})")
|
|
43
|
+
#: The contract's SECRET_VALUE at the pin (DEC-0151): `Bearer ` at a value's start in any case, or a provider key after a
|
|
44
|
+
#: boundary. No case-insensitive flag; digits are ASCII. A match includes its boundary character: callers test, never
|
|
45
|
+
#: read the matched text or its index.
|
|
46
|
+
SECRET_VALUE = re.compile("(^[Bb][Ee][Aa][Rr][Ee][Rr][" + JS_SPACE + "]" + _NOT_LINE_END + "+|" + _KEY_BOUNDARY + _PROVIDER_KEYS + ")")
|
|
35
47
|
_LONG_RUN = re.compile(r"[A-Za-z0-9+/=_-]{40,}")
|
|
36
48
|
_BEARER_INSIDE = re.compile(r"(?<![A-Za-z0-9_])bearer[%s]+(?=[A-Za-z0-9._~+/-]*[0-9])[A-Za-z0-9._~+/-]{12,}=*" % JS_SPACE,
|
|
37
49
|
re.IGNORECASE | re.ASCII)
|
|
@@ -194,7 +206,10 @@ def secret_url(value: str) -> bool:
|
|
|
194
206
|
_REPOSITORY_URI = re.compile(r"\A([A-Za-z][A-Za-z0-9+.-]*:(?://[^/?#]*)?)([^?#]*)(.*)\Z", re.S)
|
|
195
207
|
_REPOSITORY_NAME = re.compile(r"(?:[a-z0-9._-]+|[A-Za-z._-]+)\Z") # lower case, or letters with no digit (readings 8)
|
|
196
208
|
_REPOSITORY_AUTHORITY = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://")
|
|
197
|
-
|
|
209
|
+
#: A repository name's own key shapes, boundary-free and case-sensitive (DEC-0151): SECRET_VALUE takes a consuming
|
|
210
|
+
#: boundary, so composed after a separator look-behind it would miss a key after `-`, `.` or `_`.
|
|
211
|
+
_REPOSITORY_KEYS = r"sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}"
|
|
212
|
+
_KEY_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))(?:%s)" % _REPOSITORY_KEYS)
|
|
198
213
|
_JWT_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))%s" % JWT_SHAPE.pattern)
|
|
199
214
|
|
|
200
215
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -228,6 +228,8 @@ from fabric_descriptor import ( # noqa: F401
|
|
|
228
228
|
_NOT_LAUNCHD,
|
|
229
229
|
_AUTH_HEADER,
|
|
230
230
|
LoopbackHTTPServer,
|
|
231
|
+
PEER_CHECKS,
|
|
232
|
+
connection_refusal,
|
|
231
233
|
descriptor_path,
|
|
232
234
|
read_descriptors,
|
|
233
235
|
validate_descriptor,
|
|
@@ -336,6 +338,7 @@ from fabric_supervisors import ( # noqa: F401
|
|
|
336
338
|
_launchctl,
|
|
337
339
|
_domain,
|
|
338
340
|
SYSTEM_DAEMONS,
|
|
341
|
+
USER_NAME_RULE,
|
|
339
342
|
launchd_domain,
|
|
340
343
|
launchd_loaded,
|
|
341
344
|
launchd_pid,
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py
CHANGED
|
@@ -47,6 +47,8 @@ DEFAULT_EXIT_TIMEOUT = 15
|
|
|
47
47
|
|
|
48
48
|
_LABEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,250}\Z") # what launchd and the Node twin take as a label
|
|
49
49
|
_USER_NAME = re.compile(r"^[a-z_][a-z0-9_.-]{0,31}\Z") # a macOS short name, as a LaunchDaemon's UserName takes it
|
|
50
|
+
#: The short-name rule in words, printed alike by both twins (FAA-286 item 13).
|
|
51
|
+
USER_NAME_RULE = "a lower-case letter or _, then up to 31 lower-case letters, digits, _, . or -"
|
|
50
52
|
|
|
51
53
|
#: Where a LaunchDaemon's plist lives: a job in launchd's system domain, started at boot with no GUI login (FAA-278).
|
|
52
54
|
SYSTEM_DAEMONS = Path("/Library/LaunchDaemons")
|
|
@@ -373,6 +375,9 @@ def launchd_domain(plist_path: Optional[Path] = None, domain: Optional[str] = No
|
|
|
373
375
|
session; a ``gui/<uid>`` job starts only while its user is logged in."""
|
|
374
376
|
if domain not in (None, "gui", "system"):
|
|
375
377
|
raise ServiceError("domain must be gui or system")
|
|
378
|
+
if plist_path is not None and re.match(r"^(?:system|gui)(?:/[0-9]*)?\Z", str(plist_path)):
|
|
379
|
+
raise ServiceError("%s is a launchd domain, not a plist path: pass domain=%r (FAA-286)"
|
|
380
|
+
% (plist_path, "system" if str(plist_path) == "system" else "gui"))
|
|
376
381
|
if domain is None and plist_path is not None:
|
|
377
382
|
folder = re.sub(r"^/{2,}", "/", str(Path(os.path.abspath(str(plist_path))).parent)) # POSIX keeps a leading //
|
|
378
383
|
domain = "system" if folder.lower() == str(SYSTEM_DAEMONS).lower() else "gui" # APFS ignores case
|
|
@@ -384,6 +389,39 @@ def launchd_domain(plist_path: Optional[Path] = None, domain: Optional[str] = No
|
|
|
384
389
|
return _domain()
|
|
385
390
|
|
|
386
391
|
|
|
392
|
+
def _in_daemons(plist_path: Path) -> bool:
|
|
393
|
+
folder = re.sub(r"^/{2,}", "/", str(Path(os.path.abspath(str(plist_path))).parent))
|
|
394
|
+
return folder.lower() == str(SYSTEM_DAEMONS).lower()
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _pair_problem(plist_path: Path, domain: Optional[str]) -> Optional[str]:
|
|
398
|
+
"""A domain the plist's folder contradicts (FAA-286 items 14 and 21): a system job in a LaunchAgents folder (launchd
|
|
399
|
+
also loads it into gui/<uid> at login), or a gui job in /Library/LaunchDaemons (launchd loads it at boot as root).
|
|
400
|
+
Another folder is allowed for a system job, as the tests' and a staging install's are."""
|
|
401
|
+
if domain == "system" and Path(os.path.abspath(str(plist_path))).parent.name.lower() == "launchagents":
|
|
402
|
+
return ("A LaunchDaemon's plist lives in %s: %s is in a LaunchAgents folder, which launchd also loads into "
|
|
403
|
+
"gui/<uid> at login, so the job would run twice." % (SYSTEM_DAEMONS, plist_path))
|
|
404
|
+
if domain == "gui" and _in_daemons(plist_path):
|
|
405
|
+
return ("%s is in %s, which launchd loads at boot as a LaunchDaemon: a gui/<uid> job's plist goes in "
|
|
406
|
+
"~/Library/LaunchAgents." % (plist_path, SYSTEM_DAEMONS))
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _leftover_agent(label: str, user_name: str) -> Optional[Path]:
|
|
411
|
+
"""A LaunchAgent of ``label`` in ``user_name``'s ~/Library/LaunchAgents, which would run a second copy at its login
|
|
412
|
+
(FAA-286 item 6)."""
|
|
413
|
+
try:
|
|
414
|
+
import pwd
|
|
415
|
+
home = Path(pwd.getpwnam(user_name).pw_dir)
|
|
416
|
+
except (ImportError, KeyError, ValueError):
|
|
417
|
+
return None
|
|
418
|
+
try:
|
|
419
|
+
agents = sorted((home / "Library" / "LaunchAgents").glob("*.plist"))
|
|
420
|
+
except OSError:
|
|
421
|
+
return None
|
|
422
|
+
return next((p for p in agents if _plist_field(p, "Label") == label), None)
|
|
423
|
+
|
|
424
|
+
|
|
387
425
|
def _need_root(domain: str, label: str, plist_path: Path, install: bool) -> None:
|
|
388
426
|
"""A system-domain job is installed and removed by root: refuse before anything is written (FAA-278)."""
|
|
389
427
|
if domain != "system" or os.geteuid() == 0:
|
|
@@ -700,6 +738,9 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
700
738
|
read_answer = fetch_well_known or (lambda origin_, vouch_pid=None: well_known_of(origin_, vouch_pid=vouch_pid))
|
|
701
739
|
read_job = fetch_job_well_known or job_well_known_of
|
|
702
740
|
plist_path = Path(plist_path)
|
|
741
|
+
pair = _pair_problem(plist_path, domain)
|
|
742
|
+
if pair:
|
|
743
|
+
raise ServiceError(pair)
|
|
703
744
|
dom = launchd_domain(plist_path, domain)
|
|
704
745
|
which = "system" if dom == "system" else None
|
|
705
746
|
_need_root(dom, label, plist_path, True)
|
|
@@ -713,8 +754,8 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
713
754
|
"the job's pid and uid (FAA-278); %s is not." % origin)
|
|
714
755
|
try:
|
|
715
756
|
named = plistlib.loads(plist_bytes).get("UserName")
|
|
716
|
-
except Exception: #
|
|
717
|
-
|
|
757
|
+
except Exception as exc: # named as what it is, never as a missing UserName (FAA-286 item 16)
|
|
758
|
+
raise ServiceError("The plist does not parse (%s): write it with launchd_plist." % type(exc).__name__)
|
|
718
759
|
named = named if isinstance(named, str) else None # a UserName that is not a string names no account
|
|
719
760
|
if named:
|
|
720
761
|
_refuse_held_label(label, plist_path)
|
|
@@ -731,7 +772,7 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
731
772
|
"it), or uninstall that one as root first." % (plist_path, installed))
|
|
732
773
|
if named and not _USER_NAME.match(named):
|
|
733
774
|
raise ServiceError("UserName %s is not a macOS short name (%s): launchd takes the account's short name."
|
|
734
|
-
% (named,
|
|
775
|
+
% (named, USER_NAME_RULE))
|
|
735
776
|
job_uid = _account_uid(named) if named else None
|
|
736
777
|
if named and job_uid is None:
|
|
737
778
|
raise ServiceError("UserName %s is no account of this Mac: launchd would not start the job. Name the account "
|
|
@@ -744,6 +785,12 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
744
785
|
raise ServiceError("A LaunchDaemon needs UserName: without it the job runs as root, keeps its data in root's "
|
|
745
786
|
"home, and every client refuses root's listener (DEC-0154 HA-2). Write the plist with "
|
|
746
787
|
"launchd_plist(..., user_name=<the account whose Fabric and agents call it>).")
|
|
788
|
+
leftover = _leftover_agent(label, named)
|
|
789
|
+
if leftover is not None:
|
|
790
|
+
raise ServiceError("%s is a LaunchAgent of label %s: it would start a second copy at %s's login beside this "
|
|
791
|
+
"LaunchDaemon. Remove it first (launchd_uninstall as %s; lifecycle reference, The system "
|
|
792
|
+
"domain)." % (leftover, label, named, named))
|
|
793
|
+
existed = plist_path.exists()
|
|
747
794
|
atomic_write(plist_path, plist_bytes, 0o644)
|
|
748
795
|
lint = subprocess.run(["plutil", "-lint", str(plist_path)], capture_output=True, text=True)
|
|
749
796
|
if lint.returncode != 0:
|
|
@@ -771,6 +818,16 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
771
818
|
"headless Mac after a FileVault restart), so %s does not run jobs: remove the LaunchAgent "
|
|
772
819
|
"this attempt wrote (launchd_uninstall), then install it as a LaunchDaemon in the system "
|
|
773
820
|
"domain (a plist in %s with UserName, as root; FAA-278)." % (last, dom, SYSTEM_DAEMONS))
|
|
821
|
+
if dom == "system":
|
|
822
|
+
# a plist left in /Library/LaunchDaemons loads at the next boot (FAA-286 item 15): one this call wrote goes
|
|
823
|
+
if not existed:
|
|
824
|
+
try:
|
|
825
|
+
plist_path.unlink()
|
|
826
|
+
except OSError:
|
|
827
|
+
pass
|
|
828
|
+
raise ServiceError("launchctl bootstrap failed: %s; %s" % (last, "the plist this call wrote was removed"
|
|
829
|
+
if not plist_path.exists() else "%s stays and loads at the next boot: remove it as root"
|
|
830
|
+
% plist_path))
|
|
774
831
|
raise ServiceError("launchctl bootstrap failed: %s" % last)
|
|
775
832
|
deadline = time.time() + timeout
|
|
776
833
|
if dom == "system":
|
|
@@ -834,6 +891,10 @@ def launchd_uninstall(label: str, plist_path: Path, *, timeout: float = 30.0, pu
|
|
|
834
891
|
"remove the data as that user.")
|
|
835
892
|
if dom == "system":
|
|
836
893
|
_refuse_held_label(label, Path(plist_path)) # a bootout would stop another plist's job (FAA-278)
|
|
894
|
+
holds = _plist_field(Path(plist_path), "Label") if Path(plist_path).exists() else None
|
|
895
|
+
if holds is not None and holds != label: # FAA-286 item 9: booting out one job and unlinking another's plist
|
|
896
|
+
raise ServiceError("%s holds launchd label %s, not %s: nothing was removed. Name the plist of %s, or uninstall %s "
|
|
897
|
+
"with its own label." % (plist_path, holds, label, label, holds))
|
|
837
898
|
dirs = instance_dirs(str(service_id), instance) if purge else {}
|
|
838
899
|
target = "%s/%s" % (dom, label)
|
|
839
900
|
_launchctl("bootout", target)
|