@passioncode-ai/fabric-agent-adapter 0.80.2 → 0.82.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 (92) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +18 -5
  3. package/SECURITY.md +25 -8
  4. package/bin/fabric-agent-adapter.js +24 -1
  5. package/package.json +1 -1
  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 +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +16 -3
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +116 -4
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +33 -3
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +257 -10
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -77
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +899 -0
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +23 -5
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +16 -3
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -3
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +47 -10
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +31 -8
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +33 -3
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +19 -28
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +9 -6
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +146 -3
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +3 -2
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +79 -2
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +269 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -7
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +73 -3
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +89 -22
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +39 -6
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +76 -11
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +8 -153
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +86 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +324 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +34 -3
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +35 -17
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +77 -4
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +156 -133
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +257 -10
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -3
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +246 -24
  64. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
  65. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
  66. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
  67. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +16 -3
  68. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
  69. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
  70. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
  71. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
  72. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
  73. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
  74. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
  75. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
  76. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +33 -3
  77. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
  78. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
  79. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
  80. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
  81. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
  82. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
  83. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
  84. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
  85. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
  86. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  87. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
  88. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +16 -8
  89. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +46 -31
  90. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
  91. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +18 -16
  92. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -13
@@ -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
- problems = _all_problems(pkg, files)
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]))
@@ -1,35 +1,29 @@
1
1
  """peer_owner: which OS account owns each end of a loopback TCP connection.
2
2
 
3
- peer_owner 1.1.0+kit.4: Observatory's peer_owner 1.1.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` at
5
- commit f86f0622 (passioncode-ai/project-observatory-dashboard, the same licence). It differs
6
- from that file in this paragraph, in `Owner` and `Row` (NamedTuples here where it has
7
- frozen dataclasses: a dataclass with postponed annotations needs its module in sys.modules,
8
- and a service may load a kit file by its path without one), and in writing `Optional[X]`
9
- for `X | None` in its signatures (the kit's floor is Python 3.9, where `typing.get_type_hints`
10
- cannot read `X | None`), in `classify` / `classify_sid` asking for this process's own uid or
11
- SID before the system's (a client running as root or SYSTEM owns root's listener), and in
12
- two Linux and selection rules: on Linux `server_end` reads an accepted connection's own row
13
- and, for one not accept()ed yet (printed with uid 0 and inode 0), the listener row on its
14
- local address (state 0A; Observatory's 1.2.0 reading), `peer` reads a client
15
- row with inode 0 (a closed client) as unknown, and several rows of one owner are that owner;
16
- a row printed twice by a `/proc` read under churn counts once, and a `/proc` question that
17
- finds no row or several reads the table once more; ctypes is imported where it is used, so a
18
- Python without it reads netstat on macOS and imports the module everywhere. Since kit.2 (FAA-267), on
19
- macOS: the sysctl calibration also requires a child process's listener, an empty netstat reads as
20
- `TABLE_RESTRICTED`, and a caller may vouch for a listener with a pid it started (`pid_listens`,
21
- `parse_lsof_listeners`, `LSOF`, `CheckedHTTPConnection(vouch_pid=...)`, `own_connection(vouch_pid=...)`); since kit.3
22
- the child's port is waited on with `selectors` and read under one deadline (FAA-273); since kit.4 a number is ASCII
23
- digits only and a netstat with only its header reads as `TABLE_RESTRICTED` (FAA-273 items 3 and 4).
24
- A limit /proc sets: it carries a socket's creator and no pid, so a listening socket that root
25
- created and handed to another account (systemd socket activation with User=) reads as root's. Standard library only, no import of another kit module.
26
- The kit's tests are `test/test_fabric_peer_owner.py`, over `test/fixtures/peer-owner/`;
27
- its Node twin is `fabric-peer-owner.mjs` (netstat, /proc and PowerShell, no ctypes).
28
-
29
- WHY. Loopback is one per machine, not one per OS account. On a host with several accounts,
30
- every account's processes reach every service any account listens with on 127.0.0.1, and a
31
- loopback socket proves nothing about who is on the other end. This module answers that
32
- question from the host's socket table, read without privileges:
3
+ peer_owner 1.2.0+kit.6: Observatory's peer_owner 1.2.0 (passioncode-ai/project-observatory-dashboard,
4
+ `observatory/engine/peer_owner.py`, first vendored at f86f0622, read against 2180235c; the same licence), the kit's
5
+ `fabric_peer_owner` (layer L0; DEC-0154 HA-2, FAA-226, FAA-258). Observatory's 1.3.x (this account's own sockets
6
+ through libproc, DEC-0155 RT-2) is not in it (FAA-292). The kit's differences: `Owner` and `Row` are NamedTuples
7
+ (a dataclass with postponed annotations needs its module in sys.modules, and a service may load a kit file by
8
+ path); `Optional[X]` for `X | None` (the kit's floor is Python 3.9); `classify` / `classify_sid` ask for this
9
+ process's own uid or SID first (a client running as root or SYSTEM owns root's listener); on Linux `server_end`
10
+ reads an accepted connection's own row and, for one not accept()ed yet (uid 0, inode 0), the listener on its local
11
+ address (CT-254; reasons `not-accepted`, `several-rows`, `closed`), `peer` reads a client row with inode 0 as
12
+ unknown, several rows of one owner are that owner, a row printed twice counts once, and a `/proc` question that
13
+ finds no row or several reads once more; ctypes is imported where used. On macOS (kit.2 to kit.4, FAA-267,
14
+ FAA-273): the sysctl calibration also requires a child process's listener, an empty or header-only netstat is
15
+ `TABLE_RESTRICTED`, a caller may vouch for a listener with a pid it started (`pid_listens`,
16
+ `CheckedHTTPConnection(vouch_pid=...)`), the child's port is read under one deadline with `selectors`, a number is
17
+ ASCII digits only. DEC-0155 (kit.6, FAA-312): a netstat that fails, a table with no row of another uid (sysctl) or process
18
+ (netstat) and a missing connection row read as `table-restricted` (RT-1); a restricted table's listener is this
19
+ account's only on exactly 127.0.0.1 (RT-4); the vouch takes exactly 127.0.0.1 (RT-3), never from a set-uid
20
+ reader. `main` is the CLI, `listener <port>` (FAA-315). /proc carries a socket's creator, so a listener root created
21
+ and handed to another account (systemd socket activation, User=) reads as root's. Standard library only, no other
22
+ kit module. Tests: `test/test_fabric_peer_owner.py`; the Node twin is `fabric-peer-owner.mjs` (no ctypes).
23
+
24
+ WHY. Loopback is one per machine, not one per OS account. On a host with several accounts, every account's processes
25
+ reach every service any account listens with on 127.0.0.1, and a loopback socket proves nothing about who is on the
26
+ other end. This module answers that question from the host's socket table, read without privileges:
33
27
 
34
28
  listener(port) -> Owner who listens on 127.0.0.1:<port> (also 0.0.0.0, ::1, ::)
35
29
  server_end(sock) -> Owner for a client's connected socket: who accepted it
@@ -49,58 +43,51 @@ question from the host's socket table, read without privileges:
49
43
  unknown the owner could not be told; `reason` says why
50
44
  none `listener` only: nothing listens on that port
51
45
 
52
- A caller that must not leak (a client about to send a bearer, a server on a multi-account
53
- host about to read a request) treats everything but `this-account` as a refusal, `unknown`
54
- included: fail closed.
55
-
56
- HOW, ON EACH OS. Every read is bounded (MAX_TABLE_BYTES of table, TIMEOUT_SECONDS) and the
57
- answer is never cached across connections; only `listener` is cached, for LISTENER_TTL. A
58
- TIME_WAIT row is a connection already closed and is never an answer; two live rows with the same
59
- addresses are `unknown` (`several-rows`), never a guess.
60
-
61
- - macOS, primary (`sysctl`): `sysctl net.inet.tcp.pcblist_n` through ctypes, the record list
62
- `netstat` itself reads. Each connection is an `xinpcb_n` (addresses and ports) followed by an
63
- `xsocket_n` that carries `so_uid` and `so_last_pid` (XNU `bsd/netinet/in_pcb.h` and
64
- `bsd/sys/socketvar.h`, both under `#pragma pack(4)`); the two are paired by the pcb handle
65
- (`xi_inpp` equals `so_pcb`). Measured on macOS 26: about 4 ms for 770 sockets, every pid equal
66
- to the one `netstat` printed. The `xtcpcb_n` record that follows gives the TCP state
67
- (`t_state`), so listeners and TIME_WAIT rows are told apart. The layout is not an API, so the
68
- first read in a process calibrates it: it opens a connected pair of its own (listen, connect,
69
- accept) and the listener and both ends of the pair must read back with their exact addresses,
70
- this process's uid and its pid. A layout that fails calibration is not used again in that
46
+ A caller that must not leak (a client about to send a bearer, a server on a multi-account host about to read a request)
47
+ treats everything but `this-account` as a refusal, `unknown` included: fail closed.
48
+
49
+ HOW, ON EACH OS. Every read is bounded (MAX_TABLE_BYTES of table, TIMEOUT_SECONDS) and the answer is never cached across
50
+ connections; only `listener` is cached, for LISTENER_TTL. A TIME_WAIT row is a connection already closed and is never an
51
+ answer; two live rows with the same addresses are `unknown` (`several-rows`), never a guess.
52
+
53
+ - macOS, primary (`sysctl`): `sysctl net.inet.tcp.pcblist_n` through ctypes, the record list `netstat` itself reads.
54
+ Each connection is an `xinpcb_n` (addresses and ports) followed by an `xsocket_n` that carries `so_uid` and
55
+ `so_last_pid` (XNU `bsd/netinet/in_pcb.h` and `bsd/sys/socketvar.h`, both under `#pragma pack(4)`); the two are
56
+ paired by the pcb handle (`xi_inpp` equals `so_pcb`). Measured on macOS 26: about 4 ms for 770 sockets, every pid
57
+ equal to the one `netstat` printed. The `xtcpcb_n` record that follows gives the TCP state (`t_state`), so listeners
58
+ and TIME_WAIT rows are told apart. The layout is not an API, so the first read in a process calibrates it: it opens
59
+ a connected pair of its own (listen, connect, accept) and the listener and both ends of the pair must read back with
60
+ their exact addresses, this process's uid and its pid. A layout that fails calibration is not used again in that
71
61
  process.
72
- - macOS 27.2 restricts both tables for an ad-hoc-signed interpreter (a Homebrew Python, measured on 2026-11-07 on a
73
- host agent's Mac): sysctl lists only this process's own sockets and netstat prints nothing. The calibration therefore
74
- also requires a child process's listener, and an empty netstat is `unknown` with reason `table-restricted`, never
75
- another account's (FAA-267). A caller that started the server itself (an installer and its launchd job) may vouch
76
- with that pid: `lsof -p PID` asks the process, which the restriction leaves readable, and `CheckedHTTPConnection`
77
- accepts its listener on the port as this account's (`vouch_pid`). Apple's own `/usr/bin/python3` sees the tables.
78
- - macOS, fallback (`netstat`): `/usr/sbin/netstat -anv -p tcp`, its `process:pid` column, then
79
- `ps -o uid= -p PID` for the owner of the one row a question needs. Measured: about 20 ms over
80
- 846 sockets, and it names the pids of root's listeners, which `lsof` run by an ordinary account
81
- cannot see. Another local service implemented the same check this way; it is kept as the
82
- documented fallback for a macOS whose record layout the calibration refuses.
83
- - Linux (`proc`): `/proc/net/tcp` and `/proc/net/tcp6`, whose `uid` column every account may
84
- read (also under `hidepid=2`, which hides `/proc/<pid>` and not this file). No pid: that is
85
- only readable for this account's own processes, and the decision needs the uid alone.
86
- - Windows (`iphlpapi`): `GetExtendedTcpTable(TCP_TABLE_OWNER_PID_ALL)` gives each row's owning
87
- pid; `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)`, `OpenProcessToken(TOKEN_QUERY)` and
88
- `GetTokenInformation(TokenUser)` give its account SID, compared with this process's. A process
89
- that cannot be opened (another account's, usually) is `unknown`, never `another-account`:
90
- the caller refuses either way, and the reason says which it was. `Owner.uid` is None there.
62
+ - macOS 27.2 shows an ad-hoc-signed interpreter (a Homebrew Python) only its own sockets: the calibration also
63
+ requires a child's listener, and a restricted table (DEC-0155 RT-1) is `unknown` with a reason beginning
64
+ `table-restricted`, never another account's. A caller that started the server (an installer, its launchd job) may
65
+ vouch with that pid: `lsof -p PID` asks the process, which the restriction leaves readable (RT-3).
66
+ - macOS, fallback (`netstat`): `/usr/sbin/netstat -anv -p tcp`, its `process:pid` column, then `ps -o uid= -p PID` for
67
+ the owner of the one row a question needs. Measured: about 20 ms over 846 sockets, and it names the pids of root's
68
+ listeners, which `lsof` run by an ordinary account cannot see. Another local service implemented the same check this
69
+ way; it is kept as the documented fallback for a macOS whose record layout the calibration refuses.
70
+ - Linux (`proc`): `/proc/net/tcp` and `/proc/net/tcp6`, whose `uid` column every account may read (also under
71
+ `hidepid=2`, which hides `/proc/<pid>` and not this file). No pid: that is only readable for this account's own
72
+ processes, and the decision needs the uid alone.
73
+ - Windows (`iphlpapi`): `GetExtendedTcpTable(TCP_TABLE_OWNER_PID_ALL)` gives each row's owning pid;
74
+ `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)`, `OpenProcessToken(TOKEN_QUERY)` and
75
+ `GetTokenInformation(TokenUser)` give its account SID, compared with this process's. A process that cannot be opened
76
+ (another account's, usually) is `unknown`, never `another-account`: the caller refuses either way, and the reason
77
+ says which it was. `Owner.uid` is None there.
91
78
  - Anything else: `unknown`, reason `unsupported-platform`.
92
79
 
93
- `LOCAL_PEERCRED`, `getpeereid` and Linux `SO_PEERCRED` answer this question for Unix-domain
94
- sockets only, not for TCP, which is why a socket table is read here at all.
80
+ `LOCAL_PEERCRED`, `getpeereid` and Linux `SO_PEERCRED` answer this question for Unix-domain sockets only, not for TCP,
81
+ which is why a socket table is read here at all.
95
82
 
96
- WHAT IT NEVER DOES. It never reads a byte from the socket it is asked about, never resolves a
97
- name, never sends anything, and never needs a privilege. It reports the process names
98
- `netstat` prints only as far as the pid behind them.
83
+ WHAT IT NEVER DOES. It never reads a byte from the socket it is asked about, never resolves a name, never sends
84
+ anything, and never needs a privilege. It reports the process names `netstat` prints only as far as the pid behind them.
99
85
  """
100
86
  from __future__ import annotations
101
87
 
102
88
  import http.client
103
89
  import ipaddress
90
+ import json
104
91
  import os
105
92
  import socket
106
93
  import struct
@@ -111,7 +98,7 @@ import time
111
98
  from typing import NamedTuple, Optional, Tuple
112
99
 
113
100
  # This file's own version, for projects that copy it; not the engine's (that is configuration.VERSION).
114
- PEER_OWNER_VERSION = "1.1.0+kit.4" # Observatory's 1.1.0 with the kit's differences above
101
+ PEER_OWNER_VERSION = "1.2.0+kit.6" # Observatory's 1.2.0 with the kit's differences above
115
102
 
116
103
  THIS_ACCOUNT = "this-account"
117
104
  ANOTHER_ACCOUNT = "another-account"
@@ -120,9 +107,8 @@ UNKNOWN = "unknown"
120
107
  NONE = "none"
121
108
  KINDS = (THIS_ACCOUNT, ANOTHER_ACCOUNT, SYSTEM, UNKNOWN, NONE)
122
109
 
123
- #: The most table bytes one read accepts. The spec of this module asked for 1 MB; measured,
124
- #: `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 4,800.
110
+ #: The most table bytes one read accepts: 1 MB held about 1,200 sockets at `pcblist_n`'s 860 bytes each, which a busy
111
+ #: developer machine passes; 4 MiB holds about 6,800 at 616 bytes measured on macOS 26 (FAA-260).
126
112
  MAX_TABLE_BYTES = 4 * 1024 * 1024
127
113
  #: Every subprocess this module starts is stopped after this many seconds.
128
114
  TIMEOUT_SECONDS = 2.0
@@ -136,6 +122,8 @@ LSOF = "/usr/sbin/lsof"
136
122
  #: The reason a socket table hides other processes' sockets from this one (FAA-267: macOS 27.2 shows an ad-hoc-signed
137
123
  #: interpreter, such as a Homebrew Python, only its own sockets through sysctl and nothing through netstat).
138
124
  TABLE_RESTRICTED = "table-restricted"
125
+ #: A macOS table that lists sockets but not the reader's own connection (DEC-0155 RT-1, FAA-312).
126
+ NO_ROW_RESTRICTED = TABLE_RESTRICTED + ": no row for this connection"
139
127
 
140
128
  #: Client codes of `own_connection` (ListenerRefused.code).
141
129
  SERVER_NOT_OWN = "server-not-own"
@@ -162,8 +150,7 @@ class Owner(_OwnerFields):
162
150
 
163
151
 
164
152
  class Row(_RowFields):
165
- """One TCP socket of the host's table. `uid` is None where the table carries no owner
166
- (netstat, Windows): it is resolved from `pid` only for the row a question selects."""
153
+ """One TCP socket of the host's table; `uid` is None where it carries none (netstat, Windows): read from `pid`."""
167
154
  __slots__ = ()
168
155
 
169
156
 
@@ -183,9 +170,8 @@ class ListenerRefused(Exception):
183
170
  # -- addresses --------------------------------------------------------------------------------------------------------
184
171
 
185
172
  def norm_host(host: str) -> str:
186
- """One spelling per address: an IPv4-mapped IPv6 address is its IPv4 form, `*` is the
187
- wildcard, `localhost` is 127.0.0.1. Anything unparseable is returned unchanged (and matches
188
- only itself)."""
173
+ """One spelling per address: an IPv4-mapped IPv6 address is its IPv4 form, `*` is the wildcard, `localhost` is
174
+ 127.0.0.1. Anything unparseable is returned unchanged (and matches only itself)."""
189
175
  if host in ("*", ""):
190
176
  return "*"
191
177
  if host == "localhost":
@@ -210,12 +196,21 @@ def _address(addr) -> tuple[str, int]:
210
196
  # -- classification ---------------------------------------------------------------------------------------------------
211
197
 
212
198
  def _posix_uid() -> Optional[int]:
213
- """This process's uid, or None where the OS has none (Windows compares SIDs instead). The one
214
- place this module reads it: this file is vendored without its project's OS layer, so the uid
215
- is read here, once, on the POSIX paths only (an engine boundary test names this function)."""
199
+ """This process's uid, or None where the OS has none (Windows compares SIDs). The one place this module reads it:
200
+ vendored without its project's OS layer, it reads it here, on POSIX only (an engine boundary test names it)."""
216
201
  return os.getuid() if hasattr(os, "getuid") else None
217
202
 
218
203
 
204
+ def _own_pid() -> int:
205
+ """This process's pid: a netstat that shows no other process's row is a restricted table (DEC-0155 RT-1)."""
206
+ return os.getpid()
207
+
208
+
209
+ def _set_uid() -> bool:
210
+ """Real and effective uid differ: such a reader uses none of DEC-0155's proofs (RT-2 to RT-5), the vouch included."""
211
+ return hasattr(os, "geteuid") and os.geteuid() != _posix_uid()
212
+
213
+
219
214
  def classify(uid: Optional[int], pid: Optional[int], *, method: str, own_uid: Optional[int] = None) -> Owner:
220
215
  """`this-account` for this process's uid, `system` for uid 0 or pid 1, else `another-account`. A client that runs
221
216
  as root itself reads root's listener as its own account's (the kit's order; the vendored file put uid 0 first)."""
@@ -305,12 +300,11 @@ def parse_ps_uid(text: str) -> Optional[int]:
305
300
 
306
301
 
307
302
  def parse_proc_net_tcp(text: str, *, family: int, byteorder: Optional[str] = None) -> list[Row]:
308
- """`/proc/net/tcp` (family AF_INET) or `/proc/net/tcp6` (AF_INET6). The kernel prints each
309
- 32-bit word of an address in host byte order and each port in network order, in hex. A
310
- connected row with inode 0 has no socket yet (a connection the server has not accept()ed,
311
- or one already closed): the kernel prints uid 0 for it, so its uid is not read (None);
312
- `_owner_of` reads a server end not yet accepted as its listener's and a closed client as
313
- unknown (the kit's addition, 0.74.0 unit readings 3 and 4)."""
303
+ """`/proc/net/tcp` (family AF_INET) or `/proc/net/tcp6` (AF_INET6). The kernel prints each 32-bit word of an address
304
+ in host byte order and each port in network order, in hex. A connected row with inode 0 has no socket yet (a
305
+ connection the server has not accept()ed, or one already closed): the kernel prints uid 0 for it, so its uid is not
306
+ read (None); `_owner_of` reads a server end not yet accepted as its listener's and a closed client as unknown (the
307
+ kit's addition, 0.74.0 unit readings 3 and 4)."""
314
308
  order = byteorder or sys.byteorder
315
309
  rows: list[Row] = []
316
310
  for line in text.splitlines()[1:]:
@@ -619,7 +613,12 @@ def read_table() -> tuple[list[Row], str]:
619
613
  except (TableUnreadable, struct.error, ValueError):
620
614
  how = "netstat" # this read only; calibration stands
621
615
  if how == "netstat":
622
- return parse_netstat(_run([NETSTAT, "-anv", "-p", "tcp"])), how
616
+ try:
617
+ text = _run([NETSTAT, "-anv", "-p", "tcp"])
618
+ except TableUnreadable as exc:
619
+ # A netstat that fails, exits non-zero or times out is a restricted table (DEC-0155 RT-1, FAA-312).
620
+ raise TableUnreadable(f"{TABLE_RESTRICTED}: {exc}") from None
621
+ return parse_netstat(text), how
623
622
  if how == "proc":
624
623
  rows: list[Row] = []
625
624
  for name, family in (("/proc/net/tcp", socket.AF_INET), ("/proc/net/tcp6", socket.AF_INET6)):
@@ -774,6 +773,18 @@ def _windows_owner(pid: Optional[int], how: str) -> Owner: # pragma: no cover -
774
773
 
775
774
  # -- the questions ----------------------------------------------------------------------------------------------------
776
775
 
776
+ def restricted(rows: list[Row], how: str, *, own_uid: Optional[int] = None, own_pid: Optional[int] = None) -> bool:
777
+ """DEC-0155 RT-1 (FAA-312): a macOS table holding no row of another uid (sysctl, which carries uids) or of another
778
+ process (netstat, which carries pids) is restricted, and a missing row proves nothing. Never on Linux or Windows."""
779
+ if how == "sysctl":
780
+ own = _posix_uid() if own_uid is None else own_uid
781
+ return not any(row.uid is not None and row.uid != own for row in rows)
782
+ if how == "netstat":
783
+ own = _own_pid() if own_pid is None else own_pid
784
+ return not any(row.pid is not None and row.pid != own for row in rows)
785
+ return False
786
+
787
+
777
788
  def find(rows: list[Row], local: tuple[str, int], remote: tuple[str, int]) -> list[Row]:
778
789
  """The live connected rows with exactly this local and remote address: one, or the caller
779
790
  cannot tell. A TIME_WAIT row is a connection already closed and is left out. A row printed twice is one
@@ -794,19 +805,15 @@ def _owner_of(local: tuple[str, int], remote: tuple[str, int], server: bool = Fa
794
805
  if how != "proc" or len(found) == 1:
795
806
  break
796
807
  # A /proc read under churn can skip a row or print one twice; one more read before answering unknown.
797
- if not found:
798
- return Owner(UNKNOWN, None, None, how, "no-row")
808
+ if not found: # a macOS table without the reader's own connection is restricted (DEC-0155 RT-1)
809
+ return Owner(UNKNOWN, None, None, how, NO_ROW_RESTRICTED if how in ("sysctl", "netstat") else "no-row")
799
810
  if len(found) > 1:
800
811
  return Owner(UNKNOWN, None, None, how, "several-rows")
801
812
  if how == "proc" and server and found[0].uid is None:
802
- # Linux: a connection the server has not accept()ed yet is printed with uid 0 and inode 0; it belongs to the
803
- # listener on its local address (the exact address, else the wildcard), and dies with it
804
- # (inet_csk_listen_stop), so that listener answers. An accepted connection's own row carries the uid of the
805
- # process that accepted it (sock_graft): it answers itself, so a listener swapped after another account
806
- # accepted the client cannot pass (Project Observatory's 1.2.0 reading, board thread ced00f6e; the contract's
807
- # pending CT-254, FAA-258).
808
- # A server row its owner already closed (an orphan) also has inode 0 and reads so: no process holds it, so
809
- # nothing sent to it reaches anyone (0.74.0 unit reading 6).
813
+ # Linux: a connection not accept()ed yet (uid 0, inode 0) belongs to the listener on its local address (exact,
814
+ # else the wildcard) and dies with it (inet_csk_listen_stop); an accepted one's own row carries the accepting
815
+ # process's uid (sock_graft), so a listener swapped after another account accepted cannot pass (Observatory's
816
+ # 1.2.0, board thread ced00f6e; CT-254). An orphaned server row reads so too: nothing sent to it reaches anyone.
810
817
  return _one_owner(_listeners_of(rows, found[0].local), how, "not-accepted")
811
818
  if how == "proc" and found[0].uid is None:
812
819
  # A client's socket with inode 0 has already closed (0.74.0 unit reading 4): never another socket's owner.
@@ -836,9 +843,8 @@ def _one_owner(found: list[Row], how: str, none_reason: Optional[str] = None) ->
836
843
 
837
844
 
838
845
  def peer_at(server: tuple, client: tuple) -> Owner:
839
- """The owner of the CLIENT socket of the connection between `server` and `client`, as a
840
- server sees them (its own address, the peer's address): the row whose local address is the
841
- client's and whose remote address is the server's."""
846
+ """The owner of the CLIENT socket of the connection between `server` and `client`, as a server sees them (its own
847
+ address, the peer's): the row whose local address is the client's and whose remote address is the server's."""
842
848
  return _owner_of(_address(client), _address(server))
843
849
 
844
850
 
@@ -852,11 +858,10 @@ def peer(sock: socket.socket) -> Owner:
852
858
 
853
859
 
854
860
  def server_end(sock: socket.socket) -> Owner:
855
- """For a client's connected socket: who accepted it. The row whose local address is the
856
- server's and whose remote address is this socket's own, which is the server process's
857
- accepted socket, so a listener swapped after `listen` cannot pass for the one that answers.
858
- On Linux a connection not yet accept()ed has no owner of its own (inode 0): its listener
859
- answers (the contract's pending CT-254, FAA-258)."""
861
+ """For a client's connected socket: who accepted it. The row whose local address is the server's and whose remote
862
+ address is this socket's own, which is the server process's accepted socket, so a listener swapped after `listen`
863
+ cannot pass for the one that answers. On Linux a connection not yet accept()ed has no owner of its own (inode 0):
864
+ its listener 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:
@@ -882,9 +887,8 @@ def pick_listener(rows: list[Row], port: int) -> list[Row]:
882
887
 
883
888
 
884
889
  def listener(port: int) -> Owner:
885
- """Who listens on 127.0.0.1:<port>, or on 0.0.0.0 or ::1, which take the same connections.
886
- `none` when nothing does, `unknown` (`several-rows`) when sockets of two owners do. Cached for
887
- LISTENER_TTL seconds."""
890
+ """Who listens on 127.0.0.1:<port>, or on 0.0.0.0 or ::1, which take the same connections. `none` when nothing does,
891
+ `unknown` (`several-rows`) when sockets of two owners do. Cached for LISTENER_TTL seconds."""
888
892
  now = time.monotonic()
889
893
  hit = _LISTENERS.get(port)
890
894
  if hit is not None and now - hit[0] < LISTENER_TTL:
@@ -894,11 +898,18 @@ def listener(port: int) -> Owner:
894
898
  except TableUnreadable as exc:
895
899
  owner = Owner(UNKNOWN, None, None, method(), str(exc))
896
900
  else:
897
- owner = _one_owner(pick_listener(rows, port), how)
901
+ owner = _restricted_listener(rows, port, how) if restricted(rows, how) else _one_owner(pick_listener(rows, port), how)
898
902
  _LISTENERS[port] = (now, owner)
899
903
  return owner
900
904
 
901
905
 
906
+ def _restricted_listener(rows: list[Row], port: int, how: str) -> Owner:
907
+ """DEC-0155 RT-4 (FAA-312): under a restricted table only this account's own bind on exactly 127.0.0.1 is its own."""
908
+ owner = _one_owner([row for row in rows if row.listening and row.local == ("127.0.0.1", port)], how)
909
+ reason = f"{TABLE_RESTRICTED}: no listener of this account on exactly 127.0.0.1:{port}"
910
+ return owner if owner.kind == THIS_ACCOUNT else Owner(UNKNOWN, None, None, how, reason)
911
+
912
+
902
913
  def refusal(owner: Owner, port: int, expect_pid: Optional[int] = None) -> Optional[tuple[str, str]]:
903
914
  """(code, why) when a client must not send on a connection whose server end is `owner`."""
904
915
  if owner.kind in (ANOTHER_ACCOUNT, SYSTEM):
@@ -912,11 +923,11 @@ def refusal(owner: Owner, port: int, expect_pid: Optional[int] = None) -> Option
912
923
 
913
924
 
914
925
  def pid_listens(pid: int, port: int) -> bool:
915
- """macOS, when the socket table is restricted (FAA-267): whether process `pid`, this account's, listens on
916
- 127.0.0.1:<port> or the IPv4 wildcard (IPv4 alone: a v6-only wildcard never vouches for a 127.0.0.1 connection).
917
- `lsof -p` asks the process itself (libproc), which the restriction does not hide. Only a caller that started `pid`
918
- itself (a launchd job it installed) may vouch with it."""
919
- if sys.platform != "darwin" or not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1:
926
+ """macOS, when the socket table is restricted (FAA-267, DEC-0155 RT-3): whether process `pid`, this account's,
927
+ listens on exactly 127.0.0.1:<port> (never the IPv4 wildcard, which `lsof` prints as it prints an IPv6-only one, nor
928
+ ::1; FAA-312). `lsof -p` asks the process itself (libproc), which the restriction does not hide. Only a caller that
929
+ started `pid` itself (a launchd job it installed) may vouch with it, and never a set-uid one."""
930
+ if sys.platform != "darwin" or not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1 or _set_uid():
920
931
  return False
921
932
  try:
922
933
  uid = parse_ps_uid(_run([PS, "-o", "uid=", "-p", str(pid)]))
@@ -925,15 +936,13 @@ def pid_listens(pid: int, port: int) -> bool:
925
936
  out = _run([LSOF, "-nP", "-a", "-p", str(pid), "-i4TCP", "-sTCP:LISTEN", "-Fn"])
926
937
  except TableUnreadable:
927
938
  return False
928
- return any(host in ("127.0.0.1", "*") and p == port for host, p in parse_lsof_listeners(out))
939
+ return any(host == "127.0.0.1" and p == port for host, p in parse_lsof_listeners(out))
929
940
 
930
941
 
931
942
  class CheckedHTTPConnection(http.client.HTTPConnection):
932
- """An `http.client.HTTPConnection` whose every `connect()` checks the server end before
933
- returning. `http.client` connects again by itself (`auto_open`) when the server answered
934
- `Connection: close` or the caller closed the connection, and that new connection may reach a
935
- different listener; so the check is in `connect()`, not once in `own_connection`. A refused
936
- connect closes the socket and raises ListenerRefused before any byte of a request is sent."""
943
+ """An `http.client.HTTPConnection` whose every `connect()` checks the server end before returning: `http.client`
944
+ connects again by itself (`auto_open`) after `Connection: close` or a close, and that connection may reach another
945
+ listener. A refused connect closes the socket and raises ListenerRefused before any byte of a request is sent."""
937
946
 
938
947
  def __init__(self, host: str, port: int, *, expect_pid: Optional[int] = None, timeout: float = 10.0,
939
948
  vouch_pid: Optional[int] = None) -> None:
@@ -959,19 +968,33 @@ class CheckedHTTPConnection(http.client.HTTPConnection):
959
968
 
960
969
  def own_connection(port: int, *, expect_pid: Optional[int] = None, timeout: float = 10.0,
961
970
  host: str = "127.0.0.1", vouch_pid: Optional[int] = None) -> CheckedHTTPConnection:
962
- """A `CheckedHTTPConnection` to `host:port`, connected, whose server end is this account's
963
- (and, with `expect_pid`, that process's). The check runs on the very connection the caller
964
- then sends on, so no listener can change between the check and the request, and it runs
965
- again on every reconnect `http.client` makes. No proxy is consulted. Raises ListenerRefused
966
- after closing the connection:
971
+ """A `CheckedHTTPConnection` to `host:port`, connected, whose server end is this account's (and, with `expect_pid`,
972
+ that process's). The check runs on the very connection the caller then sends on, so no listener can change between
973
+ the check and the request, and again on every reconnect `http.client` makes. No proxy is consulted. Raises
974
+ ListenerRefused after closing the connection:
967
975
 
968
976
  server-not-own another account's or the system's process accepted it
969
977
  server-unverified its owner could not be told (nothing is sent: fail closed)
970
978
  server-not-this-workspace this account's process, but not the pid expected
971
979
 
972
- `vouch_pid`, as on `CheckedHTTPConnection` and the Node twin's `checkedSocket`: a pid the caller started
973
- (FAA-267).
974
- """
980
+ `vouch_pid`, as on `CheckedHTTPConnection` and the Node twin's `checkedSocket`: a pid the caller started (FAA-267)."""
975
981
  conn = CheckedHTTPConnection(host, port, expect_pid=expect_pid, timeout=timeout, vouch_pid=vouch_pid)
976
982
  conn.connect()
977
983
  return conn
984
+
985
+
986
+ def main(argv: Optional[list] = None) -> int:
987
+ """`listener <port> [--json]`: the kind of who listens on 127.0.0.1:<port>, which the dashboard link gate takes as
988
+ `listener_owner` (FAA-315), or the whole owner as JSON; exit 2 on a usage error."""
989
+ args = list(sys.argv[1:] if argv is None else argv)
990
+ as_json = args[2:] == ["--json"]
991
+ if len(args) != 2 + as_json or args[0] != "listener" or not _ascii_digits(args[1]) or not 0 < int(args[1]) < 65536:
992
+ print("usage: fabric_peer_owner.py listener <port> [--json]", file=sys.stderr)
993
+ return 2
994
+ owner = listener(int(args[1]))
995
+ print(json.dumps(owner.as_dict()) if as_json else owner.kind)
996
+ return 0
997
+
998
+
999
+ if __name__ == "__main__":
1000
+ sys.exit(main())
@@ -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",),