@passioncode-ai/fabric-agent-adapter 0.79.1 → 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 +213 -0
- package/README.md +13 -2
- package/SECURITY.md +23 -8
- package/bin/fabric-agent-adapter.js +50 -5
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +12 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +1 -1
- 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 +26 -6
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +9 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +24 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -76
- 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 +8 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
- 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/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +12 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +7 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +54 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +33 -12
- 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 +25 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
- 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 +26 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +24 -33
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +12 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +136 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +118 -3
- 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 +72 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
- 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 +7 -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 +11 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +114 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +120 -3
- 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 +40 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
- 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 +28 -13
- 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 +24 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +23 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +469 -29
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +18 -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/agents-database.md +4 -4
- 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 +12 -4
- 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/resilience-and-autonomy.md +7 -1
- 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/references/where-agents-run.md +28 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
- 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 +26 -6
- 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 +21 -21
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +3 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +143 -36
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +136 -15
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -80,7 +80,7 @@ import fabric_connections as fcn # noqa: E402
|
|
|
80
80
|
from check_service_rules import shown_repository # noqa: E402 - a repository in evidence carries no userinfo or query
|
|
81
81
|
import fabric_interop as fi # noqa: E402
|
|
82
82
|
import fabric_peer_owner as po # noqa: E402 - DEC-0154 HA-2: the listener's owner before any credential
|
|
83
|
-
from check_service_rules import ForeignUser, PORT_RULES_UNREAD, listener_owner_verdict # noqa: E402 - DEC-0154 HA-2
|
|
83
|
+
from check_service_rules import ForeignUser, PORT_RULES_UNREAD, in_launch_daemons, listener_owner_verdict, other_domain_copy, port_claim_verdict # noqa: E402 - DEC-0154 HA-2
|
|
84
84
|
|
|
85
85
|
Result = Dict[str, str]
|
|
86
86
|
|
|
@@ -97,7 +97,7 @@ from check_service_rules import usage_problems # noqa: E402,F401 - moved (FAA-6
|
|
|
97
97
|
from check_service_rules import same_repository # noqa: E402,F401 - moved (FAA-65)
|
|
98
98
|
from check_service_rules import merge_headers, session_cookie # noqa: E402,F401 - FAA-115
|
|
99
99
|
from check_service_rules import well_known_actions_verdict, action_tools_verdict, actions_declared_verdict # noqa: E402 - DEC-0121
|
|
100
|
-
from check_service_rules import opens_in_host_verdict, service_checkout # noqa: E402 - FAA-118
|
|
100
|
+
from check_service_rules import opens_in_host_verdict, service_checkout, declared_limits, holds_verdict # noqa: E402 - FAA-118, FAA-298
|
|
101
101
|
from check_service_rules import well_known_a2a_verdict, a2a_card_verdict # noqa: E402 - DEC-0122
|
|
102
102
|
import fabric_a2a as fa2 # noqa: E402 - DEC-0122
|
|
103
103
|
import check_service_channel_agent as cac # noqa: E402 - DEC-0150, FAA-197
|
|
@@ -153,6 +153,7 @@ class Probe:
|
|
|
153
153
|
|
|
154
154
|
def request(self, method: str, path: str, headers: Optional[Dict[str, str]] = None,
|
|
155
155
|
body: Optional[bytes] = None) -> Tuple[int, Dict[str, str], bytes]:
|
|
156
|
+
"""`connected_at`: when a local connect and its owner check ended, so the answer is timed from there (r11)."""
|
|
156
157
|
if self.remote:
|
|
157
158
|
context = ssl.create_default_context(cafile=self.ca_file) if self.ca_file else ssl.create_default_context()
|
|
158
159
|
if self.connect:
|
|
@@ -167,6 +168,9 @@ class Probe:
|
|
|
167
168
|
base = {"Host": "127.0.0.1:%d" % self.port}
|
|
168
169
|
base.update(headers or {})
|
|
169
170
|
try:
|
|
171
|
+
if not self.remote:
|
|
172
|
+
conn.connect()
|
|
173
|
+
self.connected_at = time.perf_counter()
|
|
170
174
|
conn.request(method, path, body=body, headers=base)
|
|
171
175
|
resp = conn.getresponse()
|
|
172
176
|
return resp.status, merge_headers(resp.getheaders()), resp.read()
|
|
@@ -203,25 +207,7 @@ class Probe:
|
|
|
203
207
|
else:
|
|
204
208
|
mode = stat.S_IMODE(os.stat(self.path).st_mode)
|
|
205
209
|
self.add("descriptor.private", "PASS" if mode & 0o077 == 0 else "FAIL", "mode %o" % mode)
|
|
206
|
-
|
|
207
|
-
clashes = []
|
|
208
|
-
for path, other in fs.read_descriptors(self.dir):
|
|
209
|
-
key = "%s.%s" % (other.get("id"), other.get("instance", "default"))
|
|
210
|
-
if path.resolve() == self.path.resolve():
|
|
211
|
-
continue
|
|
212
|
-
if key == me:
|
|
213
|
-
clashes.append("%s declared again in %s" % (me, path.name))
|
|
214
|
-
# DEC-0069: a descriptor without an origin (an app's) claims no port, so two of them never clash.
|
|
215
|
-
elif (not self.remote and "origin" in self.d and fs.placement_of(other) != "remote"
|
|
216
|
-
and other.get("origin") == self.d.get("origin")):
|
|
217
|
-
clashes.append("port %d also claimed by %s" % (self.port, key))
|
|
218
|
-
if self.remote:
|
|
219
|
-
unique = "a remote origin claims no port here"
|
|
220
|
-
elif "origin" not in self.d:
|
|
221
|
-
unique = "an app's descriptor names no origin, so it claims no port (FAC-SEM-010)"
|
|
222
|
-
else:
|
|
223
|
-
unique = "no other descriptor here declares port %d (HA-1's claim on the computer is FAA-225)" % self.port
|
|
224
|
-
self.add("descriptor.port-claim", "FAIL" if clashes else "PASS", "; ".join(clashes) or unique)
|
|
210
|
+
self.add("descriptor.port-claim", *port_claim_verdict(self.path, self.d, self.dir, self.port, self.remote))
|
|
225
211
|
# FAA-198: the repository that links the agent to its project; a new install is refused without it, an installed one
|
|
226
212
|
# is named here (NOT_RUN is no pass) until its owner adds it.
|
|
227
213
|
# A source descriptor.valid refuses is no pass either, and the evidence never carries a URL's userinfo or query,
|
|
@@ -260,9 +246,9 @@ class Probe:
|
|
|
260
246
|
try:
|
|
261
247
|
timings = []
|
|
262
248
|
for _ in range(3):
|
|
263
|
-
started = time.perf_counter()
|
|
249
|
+
started = self.connected_at = time.perf_counter()
|
|
264
250
|
status, headers, body = self.request("GET", "/.well-known/fabric-service", protected)
|
|
265
|
-
timings.append((time.perf_counter() - started) * 1000)
|
|
251
|
+
timings.append((time.perf_counter() - max(started, self.connected_at)) * 1000)
|
|
266
252
|
except OSError as exc:
|
|
267
253
|
if self.remote:
|
|
268
254
|
self.add_no_token(no_token, None)
|
|
@@ -276,7 +262,7 @@ class Probe:
|
|
|
276
262
|
try:
|
|
277
263
|
self.wk = json.loads(body)
|
|
278
264
|
self.wk_bytes = len(body) # D4 of the 0.73.0 brief: a well-known answer shares a 2 MiB cap
|
|
279
|
-
except ValueError:
|
|
265
|
+
except (ValueError, RecursionError): # Python 3.9 to 3.13 raise RecursionError on deep nesting (unit reading 10)
|
|
280
266
|
if self.remote:
|
|
281
267
|
self.add_no_token(no_token, None)
|
|
282
268
|
self.add("well-known.answers", "FAIL", "body is not JSON")
|
|
@@ -467,7 +453,7 @@ class Probe:
|
|
|
467
453
|
self.add("usage.report", "FAIL", "HTTP %d with the token" % status)
|
|
468
454
|
return
|
|
469
455
|
try:
|
|
470
|
-
report = json.loads(body)
|
|
456
|
+
report = self.usage_report = json.loads(body) # FAA-298: limits.holds reads its budgets
|
|
471
457
|
except ValueError:
|
|
472
458
|
self.add("usage.report", "FAIL", "not JSON")
|
|
473
459
|
return
|
|
@@ -673,12 +659,14 @@ class Probe:
|
|
|
673
659
|
self.events_trace_rule(self.events)
|
|
674
660
|
|
|
675
661
|
def channel_agent_rule(self) -> None:
|
|
676
|
-
"""DEC-0150 (FAA-197): the
|
|
677
|
-
|
|
678
|
-
shaped like a credential in it or in a copy the probe reads beside it (CA-5)."""
|
|
662
|
+
"""DEC-0150 (FAA-197): the declaration, from the manifest the descriptor names, else the well-known answer (CA-1); one
|
|
663
|
+
read as not declared is NOT_RUN, never a FAIL (D3), except a credential's shape in it or a copy beside it (CA-5)."""
|
|
679
664
|
manifest, _, where = self.load_manifest()
|
|
680
|
-
|
|
681
|
-
manifest, bool(self.d.get("fabricManifest")), where, self.wk, getattr(self, "wk_bytes", None))
|
|
665
|
+
try: # a copy of the probe without its schema file is NOT_RUN, and the later rules run (FAA-250)
|
|
666
|
+
verdict = cac.declaration_verdict(manifest, bool(self.d.get("fabricManifest")), where, self.wk, getattr(self, "wk_bytes", None))
|
|
667
|
+
except (OSError, ValueError) as exc:
|
|
668
|
+
verdict = ("NOT_RUN", "the probe cannot read %s beside it (%s)" % (cac.cas.SCHEMA_FILE.name, type(exc).__name__))
|
|
669
|
+
self.add("channel-agent.declaration", *verdict)
|
|
682
670
|
|
|
683
671
|
def configuration_rule(self) -> None:
|
|
684
672
|
"""DEC-0046: a well-known `configuration` is {slots, resolved} as the schema types it; absent is NOT_RUN."""
|
|
@@ -821,7 +809,7 @@ class Probe:
|
|
|
821
809
|
except (OSError, ValueError) as exc:
|
|
822
810
|
self.add("lifecycle.plist", "FAIL", "cannot read %s: %s" % (plist_path, exc))
|
|
823
811
|
return
|
|
824
|
-
problems = plist_problems(plist, life.get("label"), self.token)
|
|
812
|
+
problems = plist_problems(plist, life.get("label"), self.token, system=in_launch_daemons(plist_path), commands=self.d.get("commands"))
|
|
825
813
|
self.add("lifecycle.plist", "FAIL" if problems else "PASS", "; ".join(problems) or "%s: RunAtLoad, KeepAlive, no secrets" % plist_path.name)
|
|
826
814
|
marker = marker_problem(plist)
|
|
827
815
|
self.add("lifecycle.supervisor-marker", "FAIL" if marker else "PASS",
|
|
@@ -841,6 +829,9 @@ class Probe:
|
|
|
841
829
|
self.add("lifecycle.one-copy", "FAIL", "launchd job %s is not loaded in %s" % (life.get("label"), domain))
|
|
842
830
|
elif not match:
|
|
843
831
|
self.add("lifecycle.one-copy", "FAIL", "launchd job loaded but not running")
|
|
832
|
+
elif other_domain_copy(life.get("label"), domain): # loaded in both domains: two copies (FAA-286 item 6)
|
|
833
|
+
self.add("lifecycle.one-copy", "FAIL", "launchd job %s is loaded in %s and in %s: two copies; uninstall one"
|
|
834
|
+
% (life.get("label"), domain, other_domain_copy(life.get("label"), domain)))
|
|
844
835
|
else:
|
|
845
836
|
same = int(match.group(1)) == served
|
|
846
837
|
self.add("lifecycle.one-copy", "PASS" if same else "FAIL",
|
|
@@ -945,8 +936,8 @@ class Probe:
|
|
|
945
936
|
self.login_rules()
|
|
946
937
|
self.interop_rules()
|
|
947
938
|
self.channel_agent_rule()
|
|
948
|
-
|
|
949
|
-
self.add("board.responder",
|
|
939
|
+
self.add("limits.holds", *holds_verdict(declared_limits(self.wk, getattr(self, "usage_report", None)), *service_checkout(self.d, self.repo)))
|
|
940
|
+
self.add("board.responder", *board_responder_verdict(self.wk, self.board_kit))
|
|
950
941
|
self.lifecycle_rules()
|
|
951
942
|
return self.results
|
|
952
943
|
|
|
@@ -18,7 +18,8 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
|
18
18
|
|
|
19
19
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
20
20
|
from fabric_jsvalue import indexed_objects, js_keys # noqa: E402
|
|
21
|
-
from fabric_secrets import JS_SPACE, JWT_SHAPE, SECRET_VALUE,
|
|
21
|
+
from fabric_secrets import JS_SPACE, JWT_SHAPE, SECRET_VALUE, raw_values, secret_argument, secret_url, secret_value # noqa: E402
|
|
22
|
+
from fabric_secrets import url_parts as _whatwg_url_parts # noqa: E402 - public since FAA-250
|
|
22
23
|
import check_service_channel_agent_schema as cas # noqa: E402
|
|
23
24
|
|
|
24
25
|
KEY = "https://fabric.passioncode.ai/agent-contract/extensions/channel-agent/0.1"
|
|
@@ -62,6 +63,9 @@ _IGNORABLE = ((0x00AD, 0x00AD), (0x034F, 0x034F), (0x061C, 0x061C), (0x115F, 0x1
|
|
|
62
63
|
(0x200B, 0x200F), (0x202A, 0x202E), (0x2060, 0x206F), (0x3164, 0x3164), (0xFE00, 0xFE0F), (0xFEFF, 0xFEFF),
|
|
63
64
|
(0xFFA0, 0xFFA0), (0xFFF0, 0xFFF8), (0x1BCA0, 0x1BCA3), (0x1D173, 0x1D17A), (0xE0000, 0xE0FFF))
|
|
64
65
|
_FILLERS = frozenset("\u115f\u1160\u3164\uffa0\u2800\ufffc")
|
|
66
|
+
#: Format characters (\p{Cf}) assigned after Unicode 13, which Python 3.9's unicodedata reads as unassigned (FAA-250):
|
|
67
|
+
#: U+0890..0891 (Unicode 14) and U+13439..1343F (Unicode 15); the white-space and control categories gained none since.
|
|
68
|
+
_LATER_FORMATS = ((0x0890, 0x0891), (0x13439, 0x1343F))
|
|
65
69
|
|
|
66
70
|
|
|
67
71
|
def utf16(text: str) -> str:
|
|
@@ -75,7 +79,7 @@ def utf16(text: str) -> str:
|
|
|
75
79
|
def _blank_char(char: str) -> bool:
|
|
76
80
|
code = ord(char)
|
|
77
81
|
return (char in JS_SPACE or char in _FILLERS or unicodedata.category(char) in ("Cf", "Cc", "Zs", "Zl", "Zp")
|
|
78
|
-
or any(low <= code <= high for low, high in _IGNORABLE))
|
|
82
|
+
or any(low <= code <= high for low, high in _IGNORABLE + _LATER_FORMATS))
|
|
79
83
|
|
|
80
84
|
|
|
81
85
|
def blank(text: str) -> bool:
|
|
@@ -397,7 +401,7 @@ def url_parts(text: str) -> Optional[Tuple[str, str]]:
|
|
|
397
401
|
return None
|
|
398
402
|
if host and not _host_ok(host, special):
|
|
399
403
|
return None
|
|
400
|
-
_, query, fragment =
|
|
404
|
+
_, query, fragment = _whatwg_url_parts(text)
|
|
401
405
|
has_query = "?" in rest.partition("#")[0]
|
|
402
406
|
has_fragment = "#" in rest
|
|
403
407
|
return ("?" + query if has_query and query else "", "#" + fragment if has_fragment and fragment else "")
|
|
@@ -453,7 +457,8 @@ def secrets(value: Any, root: str, budget: Budget) -> List[Dict[str, str]]:
|
|
|
453
457
|
def flag(at: str, what: str, text: str, ident: bool) -> None:
|
|
454
458
|
if budget.named < SECRET_FINDINGS:
|
|
455
459
|
budget.named += 1
|
|
456
|
-
|
|
460
|
+
# canonical case only (DEC-0151): an upper-case SK- is no key, so the advice would name the wrong cause
|
|
461
|
+
rename = "; an id that starts with sk- is read as a key; rename it" if ident and text.startswith("sk-") else ""
|
|
457
462
|
findings.append(_finding("FAC-SEM-165", at, "%s is shaped like a credential: a token is named by its vault slot, never written%s (DEC-0150 CA-5)" % (what, rename)))
|
|
458
463
|
else:
|
|
459
464
|
budget.more += 1
|
|
@@ -823,8 +828,9 @@ def declaration_verdict(manifest: Optional[Dict[str, Any]], manifest_named: bool
|
|
|
823
828
|
where = "the manifest's provider.extensions" if site == "manifest" else "the well-known answer's extensions"
|
|
824
829
|
shapes = _credential_shapes(others + copy)
|
|
825
830
|
if shapes: # a credential's shape in a copy no reader reads still sits in a document the service publishes (CA-5)
|
|
826
|
-
|
|
827
|
-
|
|
831
|
+
also = ("; on the read declaration itself, %d finding(s): %s" % (len(own), _shown(own))) if own else ""
|
|
832
|
+
return "FAIL", "%d string(s) shaped like a credential outside the read declaration: %s (DEC-0150 CA-5)%s%s" % (
|
|
833
|
+
len(shapes), _shown(shapes), also, tail)
|
|
828
834
|
if declared and own:
|
|
829
835
|
if not channel_agent_view(doc, site)["declared"] and not _credential_shapes(own):
|
|
830
836
|
# D3: what the contract's reader reads as not declared (an older form, a repeat, a dangling reference) is
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py
CHANGED
|
@@ -3,6 +3,7 @@ network guard's verdict, the board responder's fixture conversation, a session c
|
|
|
3
3
|
whether two repository URLs name one repository, a service's actions declaration (DEC-0121) and whether its checkout
|
|
4
4
|
opens its pages in a browser (FAA-118), and its A2A member and public Agent Card (DEC-0122). ``check_service.py`` runs them against a live service.
|
|
5
5
|
Standard library only, Python 3.9+."""
|
|
6
|
+
# Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
|
|
6
7
|
from __future__ import annotations
|
|
7
8
|
|
|
8
9
|
import email.utils
|
|
@@ -21,6 +22,7 @@ import fabric_service as fs # noqa: E402 - beside this file
|
|
|
21
22
|
from fabric_http import redact_url # noqa: E402
|
|
22
23
|
from fabric_secrets import ascii_decoded, repository_name, secret_argument # noqa: E402
|
|
23
24
|
import fabric_peer_owner as po # noqa: E402 - DEC-0154 HA-2
|
|
25
|
+
import fabric_supervisors as _sup # noqa: E402 - where a LaunchDaemon's plist lives (FAA-278)
|
|
24
26
|
import socket # noqa: E402
|
|
25
27
|
|
|
26
28
|
|
|
@@ -46,7 +48,17 @@ def listener_owner_verdict(port: int) -> Tuple[str, str]:
|
|
|
46
48
|
except ConnectionRefusedError:
|
|
47
49
|
return "NOT_RUN", "nothing listens on %s (down)" % where
|
|
48
50
|
except OSError as exc: # a full backlog, a timeout: the well-known rule reports what answers, each call checked
|
|
49
|
-
|
|
51
|
+
# The listener table still names who holds the port (0.74.0 product reading 10, FAA-260 item 12): another
|
|
52
|
+
# account's or root's listener is foreign-user without a connection; any other owner is named beside NOT_RUN.
|
|
53
|
+
held = po.listener(port)
|
|
54
|
+
if held.kind in (po.ANOTHER_ACCOUNT, po.SYSTEM):
|
|
55
|
+
why = "another OS account's process" if held.kind == po.ANOTHER_ACCOUNT else "a system account's process"
|
|
56
|
+
return "FAIL", ("foreign-user: %s is listened on by %s (its listener read by %s; the connect failed with %s); "
|
|
57
|
+
"no credential was sent and nothing it answers is read as this service's"
|
|
58
|
+
% (where, why, held.method, type(exc).__name__))
|
|
59
|
+
named = held.kind if held.kind != po.UNKNOWN else "unknown (%s)" % held.reason
|
|
60
|
+
return "NOT_RUN", "could not connect to %s to tell its owner (%s); its listener reads as %s" % (
|
|
61
|
+
where, type(exc).__name__, named)
|
|
50
62
|
try:
|
|
51
63
|
owner = po.server_end(sock)
|
|
52
64
|
finally:
|
|
@@ -59,6 +71,39 @@ def listener_owner_verdict(port: int) -> Tuple[str, str]:
|
|
|
59
71
|
return "FAIL", ("foreign-user: %s is held by %s (read by %s); no credential was sent and nothing it answers is read "
|
|
60
72
|
"as this service's" % (where, why, owner.method))
|
|
61
73
|
|
|
74
|
+
def port_claim_verdict(path: Path, d: Dict[str, Any], directory: Path, port: int, remote: bool) -> Tuple[str, str]:
|
|
75
|
+
"""`descriptor.port-claim`: (verdict, evidence). Another descriptor here declares this `id.instance`, or a local
|
|
76
|
+
origin on the same port, compared by the port as the writers refuse it, so `:08080` beside `:8080` is one port
|
|
77
|
+
(0.74.0 seam reading 10, FAA-260)."""
|
|
78
|
+
me = "%s.%s" % (d.get("id"), d.get("instance", "default"))
|
|
79
|
+
clashes = []
|
|
80
|
+
for other_path, other in fs.read_descriptors(directory):
|
|
81
|
+
key = "%s.%s" % (other.get("id"), other.get("instance", "default"))
|
|
82
|
+
if other_path.resolve() == path.resolve():
|
|
83
|
+
continue
|
|
84
|
+
if key == me:
|
|
85
|
+
clashes.append("%s declared again in %s" % (me, other_path.name))
|
|
86
|
+
# DEC-0069: a descriptor without an origin (an app's) claims no port, so two of them never clash.
|
|
87
|
+
elif not remote and "origin" in d and fs.placement_of(other) != "remote" and port and _port_or_none(
|
|
88
|
+
other.get("origin")) == port:
|
|
89
|
+
clashes.append("port %d also claimed by %s" % (port, key))
|
|
90
|
+
if remote:
|
|
91
|
+
unique = "a remote origin claims no port here"
|
|
92
|
+
elif "origin" not in d:
|
|
93
|
+
unique = "an app's descriptor names no origin, so it claims no port (FAC-SEM-010)"
|
|
94
|
+
else:
|
|
95
|
+
unique = "no other descriptor here declares port %d (HA-1's claim on the computer is FAA-225)" % port
|
|
96
|
+
return ("FAIL" if clashes else "PASS"), ("; ".join(clashes) or unique)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _port_or_none(origin: Any) -> Optional[int]:
|
|
100
|
+
"""The port a local origin claims, or None for an origin the descriptor rules refuse (descriptor.valid names it)."""
|
|
101
|
+
try:
|
|
102
|
+
return fs.port_of(str(origin))
|
|
103
|
+
except fs.ServiceError:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
62
107
|
_LOCAL_PATH = re.compile(r"[/~.\\]|[A-Za-z]:[/\\]") # how a local path starts, never a user before an `@`
|
|
63
108
|
_SCP_HOST = re.compile(r"[^@/:\\]+:") # what follows the `@` of user[:password]@host:path
|
|
64
109
|
_SPECIAL = re.compile(r"(?:https?|wss?|ftp|file):", re.I) # the URL standard's special schemes, read with or without `//`
|
|
@@ -90,9 +135,37 @@ import fabric_actions as fa # noqa: E402 - beside this file (DEC-0121)
|
|
|
90
135
|
import fabric_a2a as fa2 # noqa: E402 - beside this file (DEC-0122)
|
|
91
136
|
|
|
92
137
|
|
|
93
|
-
def
|
|
94
|
-
"""
|
|
138
|
+
def other_domain_copy(label: object, domain: str) -> Optional[str]:
|
|
139
|
+
"""The other launchd domain when it also has job `label` loaded, a second copy (FAA-286 item 6), else None."""
|
|
140
|
+
other = ("gui/%d" % getattr(os, "getuid", lambda: 0)()) if domain == "system" else "system"
|
|
141
|
+
try:
|
|
142
|
+
out = subprocess.run(["launchctl", "print", "%s/%s" % (other, label)], capture_output=True, text=True, timeout=10)
|
|
143
|
+
except (OSError, subprocess.SubprocessError):
|
|
144
|
+
return None
|
|
145
|
+
return other if out.returncode == 0 else None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def in_launch_daemons(plist_path: Path) -> bool:
|
|
149
|
+
"""Whether a plist sits in /Library/LaunchDaemons, where launchd loads it at boot as a LaunchDaemon (FAA-278)."""
|
|
150
|
+
folder = re.sub(r"^/{2,}", "/", str(Path(os.path.abspath(str(plist_path))).parent))
|
|
151
|
+
return folder.lower() == str(_sup.SYSTEM_DAEMONS).lower() # the supervisors' own, which a test may point elsewhere
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def plist_problems(plist: dict, label: object, token: object = None, *, system: bool = False,
|
|
155
|
+
commands: object = None) -> list:
|
|
156
|
+
"""What makes a service's launchd job unfit to stay up: identity, restart, secrets. A LaunchDaemon (`system`) also
|
|
157
|
+
needs UserName, or it runs as root (FAA-286 item 22), and its descriptor's commands an absolute path, since a
|
|
158
|
+
`~` would be root's home when the job's update runs as root (item 23)."""
|
|
95
159
|
problems = []
|
|
160
|
+
if system and not (isinstance(plist.get("UserName"), str) and plist.get("UserName")):
|
|
161
|
+
problems.append("a LaunchDaemon without UserName runs as root, and every client refuses root's listener "
|
|
162
|
+
"(DEC-0154 HA-2)")
|
|
163
|
+
if system and isinstance(commands, dict):
|
|
164
|
+
for name, argv in sorted(commands.items()):
|
|
165
|
+
first = argv[0] if isinstance(argv, list) and argv else argv
|
|
166
|
+
if isinstance(first, str) and first.startswith("~"):
|
|
167
|
+
problems.append("commands.%s starts with ~, which is root's home for a LaunchDaemon: write the absolute "
|
|
168
|
+
"path" % name)
|
|
96
169
|
if plist.get("Label") != label:
|
|
97
170
|
problems.append("Label %r" % plist.get("Label"))
|
|
98
171
|
if plist.get("RunAtLoad") is not True:
|
|
@@ -728,3 +801,63 @@ def service_checkout(descriptor: Any, repo: Optional[str]) -> Tuple[Optional[Pat
|
|
|
728
801
|
if same_repository(_git_out(Path(top), "remote", "get-url", "origin") or "", source):
|
|
729
802
|
return Path(top), "the checkout of %s that holds %s" % (shown_repository(source), place)
|
|
730
803
|
return None, "no checkout of %s holds the descriptor's commands, manifest or launchd job; pass --repo" % shown_repository(source)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
# FAA-298: a service with limits of its own holds work visibly (fabric_holds), never silently.
|
|
807
|
+
_HOLD_USE = "HoldBook|waiting_message|waitingMessage|hold_job|holdJob" # git grep -E is POSIX: no word boundary
|
|
808
|
+
#: the kit's own copies, and a vendored copy of this file (its pattern names the kit's words): neither is a use
|
|
809
|
+
_HOLD_KIT_FILES = ("fabric_holds.py", "fabric-holds.mjs", "check_service_rules.py")
|
|
810
|
+
#: only code holds work: a README or a note naming HoldBook is no use of it (0.81.0 seam reading)
|
|
811
|
+
_HOLD_CODE = ("*.py", "*.mjs", "*.js", "*.ts")
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def declared_limits(wk: Any, usage_report: Any = None) -> List[str]:
|
|
815
|
+
"""The limits a service declares: the load declaration's `limits` (DEC-0104), and the usage report's `budget` and
|
|
816
|
+
`budgets` (DEC-0021, DEC-0027) when the probe read one. An invalid load declaration reads as not declared."""
|
|
817
|
+
import fabric_load # noqa: E402 - beside this file
|
|
818
|
+
found: List[str] = []
|
|
819
|
+
view = fabric_load.load_view(wk)
|
|
820
|
+
limits = (view.get("load") or {}).get("limits") if view.get("valid") else None
|
|
821
|
+
if isinstance(limits, dict):
|
|
822
|
+
found += ["load %s %s" % (name, limits[name]) for name in ("concurrency", "perCaller", "queueMax") if name in limits]
|
|
823
|
+
if isinstance(usage_report, dict):
|
|
824
|
+
budget = usage_report.get("budget")
|
|
825
|
+
if isinstance(budget, dict) and "limitUsd" in budget:
|
|
826
|
+
found.append("usage budget USD %s a %s" % (budget.get("limitUsd"), budget.get("period")))
|
|
827
|
+
budgets = usage_report.get("budgets")
|
|
828
|
+
if isinstance(budgets, list) and budgets:
|
|
829
|
+
found.append("usage budgets (%d)" % len(budgets))
|
|
830
|
+
return found
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def holds_verdict(limits: List[str], repo: Optional[Path], why: str,
|
|
834
|
+
budget: float = OPENS_IN_HOST_BUDGET) -> Tuple[str, str]:
|
|
835
|
+
"""`limits.holds` over one checkout: PASS when a tracked file outside the kit's own copy and the tests holds work
|
|
836
|
+
through fabric_holds (HoldBook, waiting_message, hold_job) in a code file (.py, .mjs, .js, .ts); NOT_RUN when it
|
|
837
|
+
declares none, or its checkout or git cannot be read in the budget. A service that declares limits and holds none
|
|
838
|
+
through the kit is NOT_RUN with a migration note naming fabric_holds, never FAIL: services built before the module
|
|
839
|
+
existed could not have adopted it, and whether one holds work silently or refuses it cannot be read from its tree."""
|
|
840
|
+
if not limits:
|
|
841
|
+
return "NOT_RUN", "the service declares no limits (a load declaration's limits, a usage report's budget or budgets)"
|
|
842
|
+
if repo is None:
|
|
843
|
+
return "NOT_RUN", why
|
|
844
|
+
if not shutil.which("git"):
|
|
845
|
+
return "NOT_RUN", "git is not installed"
|
|
846
|
+
try:
|
|
847
|
+
listed = subprocess.run(["git", "-C", str(repo), "grep", "-I", "-l", "-z", "-E", _HOLD_USE, "--"]
|
|
848
|
+
+ list(_HOLD_CODE), capture_output=True,
|
|
849
|
+
timeout=max(0.05, budget))
|
|
850
|
+
except subprocess.TimeoutExpired:
|
|
851
|
+
return "NOT_RUN", "the %.1f s budget ran out while git read %s" % (budget, repo)
|
|
852
|
+
except OSError as exc:
|
|
853
|
+
return "NOT_RUN", "git could not read %s: %s" % (repo, exc)
|
|
854
|
+
if listed.returncode not in (0, 1):
|
|
855
|
+
return "NOT_RUN", "git grep could not read %s" % repo
|
|
856
|
+
users = [p for p in listed.stdout.decode("utf-8", "surrogateescape").split("\0")
|
|
857
|
+
if p and p.rsplit("/", 1)[-1] not in _HOLD_KIT_FILES and not _TEST_PATH.search(p)]
|
|
858
|
+
if users:
|
|
859
|
+
return "PASS", "declares %s; %s hold(s) work through fabric_holds" % ("; ".join(limits), ", ".join(users[:5]))
|
|
860
|
+
return "NOT_RUN", ("migration: declares %s, and no tracked code file outside the kit and the tests holds work "
|
|
861
|
+
"through fabric_holds (HoldBook, waiting_message, hold_job) yet. Adopt fabric_holds so each hold "
|
|
862
|
+
"is said on the job, the operator is pushed once per hold and takes a scoped lift (FAA-298, "
|
|
863
|
+
"references/interop.md); until then this rule is not run" % "; ".join(limits))
|
|
@@ -104,6 +104,8 @@ export class Responder extends SlotKeeper {
|
|
|
104
104
|
this.state = 'idle';
|
|
105
105
|
this.waitUntil = 0;
|
|
106
106
|
this.stopping = false;
|
|
107
|
+
/** Whether shutdown found work to drain, published before it waits; null before shutdown starts (FAA-186). */
|
|
108
|
+
this.draining = null;
|
|
107
109
|
this.claimKeys = {};
|
|
108
110
|
this.probing = new Set(); // moved slots whose move-back claim lost its answer: asked again under the same key
|
|
109
111
|
this.said = {};
|
|
@@ -939,6 +941,7 @@ export class Responder extends SlotKeeper {
|
|
|
939
941
|
async shutdown(drain = DRAIN_SECONDS) {
|
|
940
942
|
this.stopping = true;
|
|
941
943
|
const work = this.current;
|
|
944
|
+
this.draining = Boolean(work); // what this shutdown waits for (FAA-186)
|
|
942
945
|
if (work && !work.done) {
|
|
943
946
|
await Promise.race([work.promise, new Promise((resolve) => setTimeout(resolve, drain * 1000).unref?.())]);
|
|
944
947
|
if (!work.done) work.ctx.stopped = true;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs
CHANGED
|
@@ -254,6 +254,7 @@ export class SlotKeeper {
|
|
|
254
254
|
if (!(e instanceof BoardError)) throw e;
|
|
255
255
|
this.retryLater(capability, entries, e);
|
|
256
256
|
if (SESSION_CODES.has(e.code)) throw e;
|
|
257
|
+
if (e.code === 'idempotency_window_expired') await this.status(); // the epoch moved: read it before the retry (FAA-186)
|
|
257
258
|
}
|
|
258
259
|
}
|
|
259
260
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// An organization's instance whose context the record does not pin (`organizations`, DEC-0127) still starts: the helper
|
|
7
7
|
// checks the placement and its share grant, not the pin (decision D5 of 0.52.0). Its tokens are refused instead:
|
|
8
8
|
// hubKeyCertified reads a context's hub keys only while the record pins that organization (DL-14a, DEC-0128), so the
|
|
9
|
-
// service passes
|
|
9
|
+
// service passes contextGateOptions(info, record) (its context and its pin's issuer, FAA-150) to its delegation gate. A refusal at start would stop every organization
|
|
10
10
|
// instance on today's records, which pin none until every reader is pinned at or after DEC-0127.
|
|
11
11
|
import crypto from 'node:crypto';
|
|
12
12
|
import fs from 'node:fs';
|
|
@@ -14,7 +14,7 @@ import path from 'node:path';
|
|
|
14
14
|
import { INSTANCE, ServiceError } from './fabric-base.mjs';
|
|
15
15
|
import { descriptorPath } from './fabric-descriptor.mjs';
|
|
16
16
|
import { parseContext } from './fabric-estate.mjs';
|
|
17
|
-
import { InstanceLock, holdSingleInstance } from './fabric-lock.mjs';
|
|
17
|
+
import { InstanceLock, LOCK_BACKOFF_FIRST_SECONDS, LOCK_BACKOFF_MAX_SECONDS, holdSingleInstance, lockWaitBudgetSeconds, supervisorMarker } from './fabric-lock.mjs';
|
|
18
18
|
import { atomicWrite, instanceDirs, servicesDir } from './fabric-paths.mjs';
|
|
19
19
|
import { instanceShareGrant } from './fabric-share.mjs';
|
|
20
20
|
import { clockMs } from './fabric-time.mjs';
|
|
@@ -112,9 +112,29 @@ function markerMatches(file, expected) {
|
|
|
112
112
|
return found !== null && typeof found === 'object' && Object.entries(expected).every(([key, value]) => found[key] === value);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* What delegationGate takes from this instance's context (FAA-150): `estateContext`, and for an organization whose
|
|
117
|
+
* context `record` pins, `issuer`: the pin's context issuer (DL-14a, DEC-0128), so an organization's instance refuses a
|
|
118
|
+
* token the personal hub key signed. `info` is contextInstance's (or holdContextInstance's) answer; spread it after the
|
|
119
|
+
* service's own estate issuer: `delegationGate({ ..., issuer: estateIssuer, ...contextGateOptions(info, record) })`.
|
|
120
|
+
* Throws ContextRefused (`context-invalid`) for an answer with no context.
|
|
121
|
+
*/
|
|
122
|
+
export function contextGateOptions(info, record) {
|
|
123
|
+
const context = info !== null && typeof info === 'object' ? info.context : undefined;
|
|
124
|
+
const parsed = parseContext(context);
|
|
125
|
+
if (!parsed) throw new ContextRefused('context-invalid');
|
|
126
|
+
const options = { estateContext: context };
|
|
127
|
+
const pins = record !== null && typeof record === 'object' && !Array.isArray(record) ? record.organizations : undefined;
|
|
128
|
+
if (parsed.kind === 'org' && Array.isArray(pins)) {
|
|
129
|
+
const pin = pins.find((p) => p !== null && typeof p === 'object' && p.org === context && typeof p.iss === 'string');
|
|
130
|
+
if (pin) options.issuer = pin.iss;
|
|
131
|
+
}
|
|
132
|
+
return options;
|
|
133
|
+
}
|
|
134
|
+
|
|
115
135
|
/**
|
|
116
136
|
* Run as `context`'s one instance, before any side effect (brief D17): contextInstance (a refusal writes one sentence
|
|
117
|
-
* naming the code and exits 78, nothing created); then holdSingleInstance on the instance's data folder (its markers,
|
|
137
|
+
* naming the code and exits 78, nothing created; a supervised copy backs off first, FAA-279); then holdSingleInstance on the instance's data folder (its markers,
|
|
118
138
|
* its supervised wait, its exit 75); then `<data>/fabric-context.json`: absent, written (0600, atomic); equal, kept;
|
|
119
139
|
* another context or instance, or unreadable, releases the lock and exits 78 (`context-mismatch`). Resolves to the
|
|
120
140
|
* instance with its held `lock`; `exit` is injectable as holdSingleInstance has it, and its answer is returned.
|
|
@@ -123,8 +143,20 @@ export async function holdContextInstance(id, context, {
|
|
|
123
143
|
record, machineId, agentRef, at, personalInstance = 'default', servicesDirectory, platform = process.platform,
|
|
124
144
|
waitSeconds, sleep, clock, exit = (code) => process.exit(code), env, argv,
|
|
125
145
|
} = {}) {
|
|
126
|
-
|
|
127
|
-
|
|
146
|
+
// FAA-279: under a supervisor that restarts on exit an exit 78 at once is a respawn every few seconds, forever; a
|
|
147
|
+
// supervised copy first waits the lock's back-off (0.5 s doubling to 30 s, 300 s in all), holding nothing.
|
|
148
|
+
const refuse = async (code) => {
|
|
149
|
+
const marker = supervisorMarker({ ...(env === undefined ? {} : { env }), ...(argv === undefined ? {} : { argv }) });
|
|
150
|
+
const wait = Math.max(0, waitSeconds ?? lockWaitBudgetSeconds(marker));
|
|
151
|
+
const tail = wait > 0 ? ` Waiting ${Math.round(wait)} s before exiting, so its supervisor does not restart it every few seconds.` : '';
|
|
152
|
+
process.stderr.write(`${id} will not run for ${shown(context)} (${code}): ${REASONS[code]}.${tail}\n`);
|
|
153
|
+
const now = clock ?? (() => performance.now() / 1000);
|
|
154
|
+
const pause = sleep ?? ((seconds) => new Promise((resolve) => { setTimeout(resolve, seconds * 1000); }));
|
|
155
|
+
const deadline = now() + wait;
|
|
156
|
+
for (let delay = LOCK_BACKOFF_FIRST_SECONDS, remaining = deadline - now(); remaining > 0; remaining = deadline - now()) {
|
|
157
|
+
await pause(Math.min(delay, remaining));
|
|
158
|
+
delay = Math.min(delay * 2, LOCK_BACKOFF_MAX_SECONDS);
|
|
159
|
+
}
|
|
128
160
|
return exit(EXIT_CONTEXT_REFUSED);
|
|
129
161
|
};
|
|
130
162
|
let info;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// How a service is found (fabric-service/0.1): its descriptor, peers and app descriptors (DEC-0061, DEC-0069), build identity and the well-known document.
|
|
2
2
|
// Part of the Node service kit (FAA-65, layer L3); the twin of fabric_descriptor.py. Node 20+, no dependency.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
3
4
|
import fs from 'node:fs';
|
|
5
|
+
import net from 'node:net';
|
|
4
6
|
import path from 'node:path';
|
|
5
7
|
import { credentialName, repositoryCredential, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
|
|
6
8
|
import { ID, INSTANCE, LOCAL_PATH, PRIMARY_CAPABILITY, PROTOCOL, ServiceError, TASK, UNIT, expand } from './fabric-base.mjs';
|
|
7
9
|
import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
|
|
10
|
+
import { ANOTHER_ACCOUNT, SYSTEM, THIS_ACCOUNT, UNKNOWN, normHost, owner as peerOwnerOf, peer as peerOfSocket } from './fabric-peer-owner.mjs'; // L0: who listens on a port and who connected (DEC-0154 HA-2, FAA-225, FAA-227)
|
|
8
11
|
import { protocolsFindings } from './fabric-protocols.mjs';
|
|
9
12
|
import { CONNECTIONS_EXTENSION_KEY, connectionsView } from './fabric-connections.mjs';
|
|
10
13
|
import { ACTIONS_EXTENSION_KEY, actionsExtension, actionsExtensionRules } from './fabric-actions.mjs';
|
|
@@ -313,9 +316,119 @@ export function sourceRefusal(d, dir = servicesDir()) {
|
|
|
313
316
|
return `Descriptor ${d?.id}.${instance} ${NO_SOURCE_REPOSITORY}. ${PASS_SOURCE}`;
|
|
314
317
|
}
|
|
315
318
|
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
+
// The child that answers portOwner: a plain listen on 127.0.0.1:<port> that succeeds means nothing holds it (the table
|
|
320
|
+
// is not read); one that fails asks fabric-peer-owner's listener. A child, because writeDescriptor is synchronous.
|
|
321
|
+
const PORT_OWNER_CHILD = `
|
|
322
|
+
import net from 'node:net';
|
|
323
|
+
const [port, url] = process.argv.slice(1);
|
|
324
|
+
const say = (value) => process.stdout.write(JSON.stringify(value));
|
|
325
|
+
const server = net.createServer();
|
|
326
|
+
server.once('error', async () => {
|
|
327
|
+
try { const { listener } = await import(url); const who = await listener(Number(port)); say([who.kind, Number.isInteger(who.uid) ? who.uid : null]); }
|
|
328
|
+
catch { say(['unknown', null]); }
|
|
329
|
+
});
|
|
330
|
+
server.listen({ port: Number(port), host: '127.0.0.1', exclusive: true }, () => server.close(() => say(null)));
|
|
331
|
+
`;
|
|
332
|
+
|
|
333
|
+
// How a loopback server treats the OS account of the process that connected (DEC-0154 HA-2's server side, HA-3;
|
|
334
|
+
// FAA-227): `refuse` (the default) closes a connection whose peer is another OS account's before reading a byte of it
|
|
335
|
+
// and serves the rest, labelled; `strict` also refuses a peer whose owner cannot be told and a system account other
|
|
336
|
+
// than root (uid 0, which can read every account's files and installs a LaunchDaemon); `off` reads no table. The twin
|
|
337
|
+
// of fabric_descriptor.py's PEER_CHECKS and LoopbackHTTPServer.
|
|
338
|
+
export const PEER_CHECKS = Object.freeze(['refuse', 'strict', 'off']);
|
|
339
|
+
|
|
340
|
+
/** Why a server refuses the peer `owner` (a fabric-peer-owner owner) under `mode`, or null to serve it: the owner's
|
|
341
|
+
* kind, or for `unknown` its reason; never an address or anything the peer sent. */
|
|
342
|
+
export function connectionRefusal(owner, mode = 'refuse') {
|
|
343
|
+
if (!PEER_CHECKS.includes(mode)) throw new ServiceError(`peerCheck must be one of ${PEER_CHECKS.join(', ')}.`);
|
|
344
|
+
if (mode === 'off' || owner === null || owner === undefined) return null;
|
|
345
|
+
const kind = owner.kind ?? UNKNOWN;
|
|
346
|
+
if (kind === ANOTHER_ACCOUNT) return kind;
|
|
347
|
+
if (mode === 'strict' && kind !== THIS_ACCOUNT) {
|
|
348
|
+
if (kind === SYSTEM && owner.uid === 0) return null;
|
|
349
|
+
return owner.reason || kind;
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Whether a connection's peer address is a loopback one (127.0.0.0/8, ::1, or either IPv4-mapped): the only peers
|
|
355
|
+
* whose owner can be another account of this computer (DEC-0154 HA-2). An address that cannot be read counts as
|
|
356
|
+
* loopback, so its owner is still read from the table. The twin of fabric_descriptor.py's is_loopback_peer. */
|
|
357
|
+
export function isLoopbackPeer(address) {
|
|
358
|
+
if (typeof address !== 'string') return true;
|
|
359
|
+
const host = normHost(address);
|
|
360
|
+
if (net.isIPv4(host)) return host.split('.')[0] === '127';
|
|
361
|
+
if (net.isIPv6(host)) return host === '::1';
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const connectionOwners = new WeakMap();
|
|
366
|
+
|
|
367
|
+
/** The owner of the peer behind an HTTP request's socket (`req.socket`) a loopbackServer served, or null. */
|
|
368
|
+
export function connectionOwner(socket) {
|
|
369
|
+
return connectionOwners.get(socket) ?? null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** A net.Server to listen on in place of `httpServer` (DEC-0154 HA-2's server side, FAA-227): each connection arrives
|
|
373
|
+
* paused, its peer is told from the computer's socket table (`peer`, fabric-peer-owner's by default), and only a peer
|
|
374
|
+
* `peerCheck` serves is handed to `httpServer` (never listened on itself), which then reads the request; a refused one
|
|
375
|
+
* is destroyed unread, counted in `peerRefusals`, and reported to `onRefused(owner, reason)` (default: one line on
|
|
376
|
+
* stderr with the owner's kind, uid and pid). Handlers find their peer's owner with `connectionOwner(req.socket)`.
|
|
377
|
+
* The check applies to loopback peers only: a peer at another address (a server bound to 0.0.0.0) is no local
|
|
378
|
+
* account, so its table is not read and it is served under `refuse` and `strict` alike, with no owner.
|
|
379
|
+
* Close the returned server to stop accepting; `httpServer.closeAllConnections()` ends the served ones. */
|
|
380
|
+
export function loopbackServer(httpServer, { peerCheck = 'refuse', peer = peerOfSocket, onRefused } = {}) {
|
|
381
|
+
connectionRefusal(null, peerCheck);
|
|
382
|
+
const report = onRefused ?? ((who, reason) => process.stderr.write(
|
|
383
|
+
`fabric: refused a loopback peer (${reason}, uid ${who?.uid ?? 'None'}, pid ${who?.pid ?? 'None'}) before reading it (DEC-0154)\n`));
|
|
384
|
+
const gate = net.createServer({ pauseOnConnect: true }, async (socket) => {
|
|
385
|
+
let who = null;
|
|
386
|
+
if (peerCheck !== 'off' && isLoopbackPeer(socket.remoteAddress)) {
|
|
387
|
+
try {
|
|
388
|
+
who = await peer(socket);
|
|
389
|
+
} catch (err) {
|
|
390
|
+
who = peerOwnerOf(UNKNOWN, { reason: `reader: ${err?.name ?? 'Error'}` });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const reason = connectionRefusal(who, peerCheck);
|
|
394
|
+
if (reason !== null) {
|
|
395
|
+
gate.peerRefusals += 1;
|
|
396
|
+
socket.destroy();
|
|
397
|
+
report(who, reason);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (socket.destroyed) return;
|
|
401
|
+
if (who !== null) connectionOwners.set(socket, who);
|
|
402
|
+
httpServer.emit('connection', socket);
|
|
403
|
+
socket.resume();
|
|
404
|
+
});
|
|
405
|
+
gate.peerRefusals = 0;
|
|
406
|
+
return gate;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Who listens on 127.0.0.1:<port> now, as [kind, uid] from the kit's fabric-peer-owner, or null when nothing holds it
|
|
410
|
+
* (FAA-225, DEC-0154 HA-2). */
|
|
411
|
+
export function portOwner(port) {
|
|
412
|
+
const done = spawnSync(process.execPath, ['--input-type=module', '-e', PORT_OWNER_CHILD, String(port),
|
|
413
|
+
new URL('./fabric-peer-owner.mjs', import.meta.url).href], { encoding: 'utf8', timeout: 10000 });
|
|
414
|
+
try { return JSON.parse(done.stdout); } catch { return ['unknown', null]; }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Why an install may not take `port`: another OS account listens on it (FAA-225). This account, the system (launchd
|
|
418
|
+
* holds a socket-activated job's socket), nobody and an unreadable table pass. The recorded port per account and its
|
|
419
|
+
* claim file wait on contract DEC-0160. */
|
|
420
|
+
export function portListenerRefusal(port, reader = portOwner) {
|
|
421
|
+
const found = reader(port);
|
|
422
|
+
if (!Array.isArray(found) || found[0] !== ANOTHER_ACCOUNT) return null;
|
|
423
|
+
const uid = Number.isInteger(found[1]) ? ` (uid ${found[1]})` : '';
|
|
424
|
+
return `Port ${port} is listened on by another OS account${uid}: choose a port no other account holds (DEC-0154, FAA-225).`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim), and a port another OS
|
|
428
|
+
* account listens on (FAA-225; `portOwner(port) -> [kind, uid] | null` reads it, the kit's own by default).
|
|
429
|
+
* FAA-198: a new descriptor names its source.repository; rewriting one already installed without it only warns
|
|
430
|
+
* (process.emitWarning, code FABRIC_NO_SOURCE_REPOSITORY), so an installed agent's update keeps working. */
|
|
431
|
+
export function writeDescriptor(d, dir = servicesDir(), { portOwner: reader = portOwner } = {}) {
|
|
319
432
|
const problems = validateDescriptor(d);
|
|
320
433
|
if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
|
|
321
434
|
const me = `${d.id}.${d.instance}`;
|
|
@@ -334,6 +447,8 @@ export function writeDescriptor(d, dir = servicesDir()) {
|
|
|
334
447
|
try { otherPort = portOf(String(other.origin ?? '')); } catch { continue; }
|
|
335
448
|
if (otherPort === port) throw new ServiceError(`Port ${port} is already claimed by ${key} (${file}).`);
|
|
336
449
|
}
|
|
450
|
+
const held = port === null ? null : portListenerRefusal(port, reader);
|
|
451
|
+
if (held) throw new ServiceError(held);
|
|
337
452
|
ensurePrivateDir(dir);
|
|
338
453
|
const target = descriptorPath(d.id, d.instance, dir);
|
|
339
454
|
atomicWrite(target, `${JSON.stringify(d, null, 2)}\n`, 0o600);
|