@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/fabric_descriptor.py
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
"""How a service is found (fabric-service/0.1): its descriptor, peers and app descriptors (DEC-0061, DEC-0069),
|
|
2
2
|
build identity and the well-known document, the loopback HTTP server. Part of the service kit (FAA-65, layer L3)."""
|
|
3
|
+
# Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
|
|
3
4
|
from __future__ import annotations
|
|
4
5
|
|
|
5
6
|
import hashlib
|
|
6
7
|
import http.server
|
|
8
|
+
import ipaddress
|
|
7
9
|
import json
|
|
8
10
|
import os
|
|
9
11
|
import re
|
|
12
|
+
import socket
|
|
10
13
|
import socketserver
|
|
11
14
|
import subprocess
|
|
15
|
+
import threading
|
|
12
16
|
import warnings
|
|
13
17
|
from pathlib import Path
|
|
14
18
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
@@ -29,6 +33,7 @@ from fabric_base import (
|
|
|
29
33
|
expand,
|
|
30
34
|
now_iso,
|
|
31
35
|
)
|
|
36
|
+
import fabric_peer_owner # noqa: E402 - L0: who listens on a port (DEC-0154 HA-2, FAA-225)
|
|
32
37
|
from fabric_paths import (
|
|
33
38
|
atomic_write,
|
|
34
39
|
ensure_private_dir,
|
|
@@ -85,19 +90,101 @@ _NOT_LAUNCHD = ("systemd", "task-scheduler", "node", "person")
|
|
|
85
90
|
_AUTH_HEADER = re.compile(r"[A-Za-z][A-Za-z0-9-]{0,63}")
|
|
86
91
|
|
|
87
92
|
|
|
93
|
+
#: How a loopback server treats the OS account of the process that connected (DEC-0154 HA-2's server side, HA-3;
|
|
94
|
+
#: FAA-227): `refuse` (the default) closes a connection whose peer is another OS account's before reading a byte of
|
|
95
|
+
#: it and serves the rest, labelled; `strict` also refuses a peer whose owner cannot be told and a system account
|
|
96
|
+
#: other than root (uid 0, which can read every account's files and installs a LaunchDaemon); `off` reads no table.
|
|
97
|
+
PEER_CHECKS = ("refuse", "strict", "off")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def connection_refusal(owner: Any, mode: str = "refuse") -> Optional[str]:
|
|
101
|
+
"""Why a server refuses the peer `owner` (a fabric_peer_owner.Owner) under `mode`, or None to serve it. The reason
|
|
102
|
+
is the owner's kind, or for `unknown` its reason; it names no address and nothing the peer sent."""
|
|
103
|
+
if mode not in PEER_CHECKS:
|
|
104
|
+
raise ValueError("peer_check must be one of %s" % ", ".join(PEER_CHECKS))
|
|
105
|
+
if mode == "off" or owner is None:
|
|
106
|
+
return None
|
|
107
|
+
kind = getattr(owner, "kind", fabric_peer_owner.UNKNOWN)
|
|
108
|
+
if kind == fabric_peer_owner.ANOTHER_ACCOUNT:
|
|
109
|
+
return kind
|
|
110
|
+
if mode == "strict" and kind != fabric_peer_owner.THIS_ACCOUNT:
|
|
111
|
+
if kind == fabric_peer_owner.SYSTEM and getattr(owner, "uid", None) == 0:
|
|
112
|
+
return None
|
|
113
|
+
return getattr(owner, "reason", None) or kind
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_loopback_peer(client_address: Any) -> bool:
|
|
118
|
+
"""Whether an accepted connection's peer address is a loopback one (127.0.0.0/8, ::1, or either IPv4-mapped): the
|
|
119
|
+
only peers whose owner can be another account of this computer (DEC-0154 HA-2). An address that cannot be read
|
|
120
|
+
counts as loopback, so its owner is still read from the table."""
|
|
121
|
+
try:
|
|
122
|
+
return ipaddress.ip_address(fabric_peer_owner.norm_host(str(client_address[0]))).is_loopback
|
|
123
|
+
except Exception:
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
|
|
88
127
|
class LoopbackHTTPServer(http.server.ThreadingHTTPServer):
|
|
89
128
|
"""ThreadingHTTPServer for a loopback service: threads are daemons, and the bind asks
|
|
90
129
|
no resolver. HTTPServer.server_bind() calls socket.getfqdn() between bind() and listen();
|
|
91
130
|
on a Mac with a slow resolver the port then stays bound but silent, so a host sees
|
|
92
|
-
neither an answer nor a refusal.
|
|
131
|
+
neither an answer nor a refusal.
|
|
132
|
+
|
|
133
|
+
Each accepted connection's peer is told from the computer's socket table (`fabric_peer_owner.peer`) in the
|
|
134
|
+
connection's own thread, before the handler reads a byte (DEC-0154 HA-2's server side, FAA-227). `peer_check`
|
|
135
|
+
(`PEER_CHECKS`) decides which owners are closed unread; a served handler finds its peer's owner with
|
|
136
|
+
`self.server.peer_owner()`. A refusal writes one line to stderr with the owner's kind, uid and pid and nothing the
|
|
137
|
+
peer sent (`peer_refused`), and counts in `peer_refusals`. The check applies to loopback peers only: a peer at
|
|
138
|
+
another address (a service bound to 0.0.0.0) is no local account, so its table is not read and it is served under
|
|
139
|
+
`refuse` and `strict` alike, with `peer_owner()` None."""
|
|
93
140
|
|
|
94
141
|
daemon_threads = True
|
|
142
|
+
peer_check = "refuse"
|
|
143
|
+
#: The reader of an accepted socket's peer; a test replaces it.
|
|
144
|
+
peer_reader = staticmethod(lambda sock: fabric_peer_owner.peer(sock))
|
|
145
|
+
|
|
146
|
+
def __init__(self, server_address: Any, handler: Any, bind_and_activate: bool = True, *,
|
|
147
|
+
peer_check: Optional[str] = None) -> None:
|
|
148
|
+
mode = self.peer_check if peer_check is None else peer_check
|
|
149
|
+
connection_refusal(None, mode) # refuses an unknown mode before the port is bound
|
|
150
|
+
self.peer_check = mode
|
|
151
|
+
self.peer_refusals = 0
|
|
152
|
+
self._peer = threading.local()
|
|
153
|
+
super().__init__(server_address, handler, bind_and_activate)
|
|
95
154
|
|
|
96
155
|
def server_bind(self) -> None:
|
|
97
156
|
socketserver.TCPServer.server_bind(self)
|
|
98
157
|
host, port = self.server_address[:2]
|
|
99
158
|
self.server_name, self.server_port = host, port
|
|
100
159
|
|
|
160
|
+
def peer_owner(self) -> Any:
|
|
161
|
+
"""The owner of the peer this thread's handler serves (None under `peer_check="off"`)."""
|
|
162
|
+
return getattr(self._peer, "owner", None)
|
|
163
|
+
|
|
164
|
+
def finish_request(self, request: Any, client_address: Any) -> None:
|
|
165
|
+
owner = None
|
|
166
|
+
if self.peer_check != "off" and is_loopback_peer(client_address):
|
|
167
|
+
try:
|
|
168
|
+
owner = self.peer_reader(request)
|
|
169
|
+
except Exception as exc: # a reader that fails is an owner nobody can tell, never a crash of the server
|
|
170
|
+
owner = fabric_peer_owner.Owner(fabric_peer_owner.UNKNOWN, None, None, "none",
|
|
171
|
+
"reader: %s" % type(exc).__name__)
|
|
172
|
+
reason = connection_refusal(owner, self.peer_check)
|
|
173
|
+
if reason is not None:
|
|
174
|
+
self.peer_refusals += 1
|
|
175
|
+
self.peer_refused(owner, reason)
|
|
176
|
+
return # process_request_thread closes the socket unread (shutdown_request)
|
|
177
|
+
self._peer.owner = owner
|
|
178
|
+
try:
|
|
179
|
+
super().finish_request(request, client_address)
|
|
180
|
+
finally:
|
|
181
|
+
self._peer.owner = None
|
|
182
|
+
|
|
183
|
+
def peer_refused(self, owner: Any, reason: str) -> None:
|
|
184
|
+
"""One line on stderr per refused connection; override to log it elsewhere. Never the peer's bytes."""
|
|
185
|
+
_sys.stderr.write("fabric: refused a loopback peer (%s, uid %s, pid %s) before reading it (DEC-0154)\n"
|
|
186
|
+
% (reason, getattr(owner, "uid", None), getattr(owner, "pid", None)))
|
|
187
|
+
|
|
101
188
|
|
|
102
189
|
def descriptor_path(service_id: str, instance: str = "default", directory: Optional[Path] = None) -> Path:
|
|
103
190
|
return (directory or services_dir()) / ("%s.%s.json" % (service_id, instance))
|
|
@@ -477,8 +564,35 @@ def source_refusal(descriptor: Dict[str, Any], directory: Optional[Path] = None)
|
|
|
477
564
|
return "Descriptor %s %s. %s" % (me, NO_SOURCE_REPOSITORY, PASS_SOURCE)
|
|
478
565
|
|
|
479
566
|
|
|
480
|
-
def
|
|
481
|
-
"""
|
|
567
|
+
def port_owner(port: int) -> Optional[Tuple[str, Optional[int]]]:
|
|
568
|
+
"""Who listens on 127.0.0.1:<port> now, as (kind, uid) from the kit's fabric_peer_owner, or None when a plain bind
|
|
569
|
+
of that address succeeds (nothing holds it, and the socket table is not read). FAA-225, DEC-0154 HA-2."""
|
|
570
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
571
|
+
try:
|
|
572
|
+
probe.bind(("127.0.0.1", port))
|
|
573
|
+
return None
|
|
574
|
+
except OSError:
|
|
575
|
+
pass
|
|
576
|
+
owner = fabric_peer_owner.listener(port)
|
|
577
|
+
return owner.kind, owner.uid
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def port_listener_refusal(port: int, reader: Optional[Any] = None) -> Optional[str]:
|
|
581
|
+
"""Why an install may not take ``port``: another OS account listens on it (FAA-225). This account, the system
|
|
582
|
+
(launchd holds a socket-activated job's socket), nobody and an unreadable table pass: the refusal is for a port
|
|
583
|
+
that is surely another account's. The recorded port per account and its claim file wait on contract DEC-0160."""
|
|
584
|
+
found = (reader or port_owner)(port)
|
|
585
|
+
if not found or found[0] != fabric_peer_owner.ANOTHER_ACCOUNT:
|
|
586
|
+
return None
|
|
587
|
+
uid = " (uid %d)" % found[1] if isinstance(found[1], int) else ""
|
|
588
|
+
return ("Port %d is listened on by another OS account%s: choose a port no other account holds (DEC-0154, "
|
|
589
|
+
"FAA-225)." % (port, uid))
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = None, *, _stacklevel: int = 2,
|
|
593
|
+
port_owner: Optional[Any] = None) -> Path:
|
|
594
|
+
"""Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim), and a port another
|
|
595
|
+
OS account listens on (FAA-225; ``port_owner(port) -> (kind, uid) | None`` reads it, the kit's own by default).
|
|
482
596
|
|
|
483
597
|
FAA-198: a new descriptor names its ``source.repository``; rewriting one already installed without it only
|
|
484
598
|
warns (``NoSourceRepositoryWarning``), so an installed agent's update keeps working."""
|
|
@@ -505,6 +619,9 @@ def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = Non
|
|
|
505
619
|
continue
|
|
506
620
|
if other_port == port:
|
|
507
621
|
raise ServiceError("Port %d is already claimed by %s (%s)." % (port, key, path))
|
|
622
|
+
held = port_listener_refusal(port, port_owner) if port is not None else None
|
|
623
|
+
if held:
|
|
624
|
+
raise ServiceError(held)
|
|
508
625
|
ensure_private_dir(root)
|
|
509
626
|
target = descriptor_path(descriptor["id"], descriptor["instance"], root)
|
|
510
627
|
atomic_write(target, (json.dumps(descriptor, indent=2, ensure_ascii=False) + "\n").encode(), 0o600)
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Work a service holds by its own limits, said to its caller and its operator (FAA-298). Part of the service kit
|
|
2
|
+
(layer L3, after fabric_interop).
|
|
3
|
+
|
|
4
|
+
A service that holds work by a limit of its own (a daily quota, a per-caller share, a concurrency cap, a money cap)
|
|
5
|
+
says so three ways:
|
|
6
|
+
|
|
7
|
+
- to the caller, on the job: `waiting_message(reason, since, until, message)` is the job's `statusMessage`. The pinned
|
|
8
|
+
`interop-job` schema is closed (`additionalProperties: false`), so `waiting {reason, since, until, message}` cannot
|
|
9
|
+
be a field of the job; it rides the contract's pause grammar (DEC-0087) as its text:
|
|
10
|
+
`quota-exhausted[ until <until>]: waiting <reason> since <since>[: <message>]`. A caller that reads only the pause
|
|
11
|
+
grammar waits (and until `until`, when given); `read_waiting(answer)` gives the four parts back. `JobStore.pause`
|
|
12
|
+
takes the same statusMessage through `hold_job`.
|
|
13
|
+
- to the operator, once per hold: `HoldBook.hold` opens a hold per (reason, caller), or joins the open one, and returns
|
|
14
|
+
the push only when it opens: one `notify` event naming the caller, the work, the reason and when the hold frees by
|
|
15
|
+
itself, with the question whether to raise the limit. Further jobs held by the same limit for the same caller join
|
|
16
|
+
the hold silently; a hold is closed by `release`, by its `until` passing, or by a lift that covers it.
|
|
17
|
+
- the answer, as a scoped lift: `HoldBook.lift` records the operator's words (a DEC-0121 decision, action
|
|
18
|
+
`limit.lift`) with its scope (the reason, optionally one caller and one job) and its size (a number of jobs, an
|
|
19
|
+
amount of money, or a raised limit for a time window such as 100 a day for today). `admit` spends a jobs or money
|
|
20
|
+
lift; `limit` gives the limit in force. A lift falls back by itself when its window ends or its amount is spent.
|
|
21
|
+
|
|
22
|
+
`HoldBook` keeps one JSON file (0600, written atomically); a ThreadingHTTPServer's handlers share it under the store
|
|
23
|
+
lock. It holds caller names, work labels, reasons and the operator's words, never a credential: `hold` refuses a text
|
|
24
|
+
the contract's detector reads as one.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import re
|
|
30
|
+
import uuid
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
33
|
+
|
|
34
|
+
import sys as _sys # noqa: E402
|
|
35
|
+
from pathlib import Path as _Path # noqa: E402
|
|
36
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
37
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
38
|
+
from fabric_base import ServiceError, now_iso, store_lock # noqa: E402
|
|
39
|
+
from fabric_paths import atomic_write # noqa: E402
|
|
40
|
+
from fabric_actions import decision_shape # noqa: E402
|
|
41
|
+
from fabric_interop_rules import pause_message, read_job_pause, utc_time, QUOTA_EXHAUSTED # noqa: E402
|
|
42
|
+
import fabric_secrets # noqa: E402
|
|
43
|
+
|
|
44
|
+
#: The limits a hold names: a daily quota, a per-caller share, a concurrency cap, a money cap.
|
|
45
|
+
HOLD_REASONS = ("daily-quota", "caller-share", "concurrency", "money")
|
|
46
|
+
#: How the push names each reason to a person.
|
|
47
|
+
REASON_WORDS = {"daily-quota": "the daily quota", "caller-share": "its per-caller share",
|
|
48
|
+
"concurrency": "the concurrency cap", "money": "the money cap"}
|
|
49
|
+
#: The push's kind: a decision for the operator (level notice, notify true; events-and-notifications.md).
|
|
50
|
+
HOLD_EVENT_KIND = "limit.held"
|
|
51
|
+
#: The record of a lift applied (level info, no notify).
|
|
52
|
+
LIFT_EVENT_KIND = "limit.lifted"
|
|
53
|
+
#: The decision action a lift carries (DEC-0121 AP-2 shape).
|
|
54
|
+
LIFT_ACTION = "limit.lift"
|
|
55
|
+
_MAX_STATUS_MESSAGE = 500
|
|
56
|
+
_WAITING = re.compile(r"^waiting (daily-quota|caller-share|concurrency|money) since ([0-9][0-9TZ:.-]*?)(?:: ([\s\S]+))?\Z")
|
|
57
|
+
_SIZES = ("jobs", "usd", "limit")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _instant(value: Any, name: str) -> str:
|
|
61
|
+
if utc_time(value) is None:
|
|
62
|
+
raise ServiceError("%s is a UTC instant (Z, at most 3 fraction digits)." % name)
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def waiting_message(reason: str, since: str, until: Optional[str] = None, message: Optional[str] = None) -> str:
|
|
67
|
+
"""A held job's statusMessage: the pause grammar, its text `waiting <reason> since <since>[: <message>]`. The
|
|
68
|
+
message is shortened, never refused, to fit the contract's 500 characters; a reason or an instant outside the
|
|
69
|
+
grammar raises ServiceError."""
|
|
70
|
+
if reason not in HOLD_REASONS:
|
|
71
|
+
raise ServiceError("A hold's reason is one of %s." % ", ".join(HOLD_REASONS))
|
|
72
|
+
_instant(since, "since")
|
|
73
|
+
if until is not None:
|
|
74
|
+
_instant(until, "until")
|
|
75
|
+
text = "waiting %s since %s" % (reason, since)
|
|
76
|
+
head = len(QUOTA_EXHAUSTED) + (0 if until is None else len(" until ") + len(until)) + len(": ")
|
|
77
|
+
if message is not None:
|
|
78
|
+
message = " ".join(str(message).split())
|
|
79
|
+
room = _MAX_STATUS_MESSAGE - head - len(text) - len(": ")
|
|
80
|
+
if message and room > 0:
|
|
81
|
+
text += ": " + (message if len(message) <= room else message[:room - 3].rstrip() + "...")
|
|
82
|
+
return pause_message(until, text)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def read_waiting(answer: Any) -> Optional[Dict[str, Any]]:
|
|
86
|
+
"""`{reason, since, until?, message?}` of a fabric.job.get answer whose job a limit holds (`waiting_message`), else
|
|
87
|
+
None: a plain pause, a queue position or a finished job is not a hold."""
|
|
88
|
+
pause = read_job_pause(answer)
|
|
89
|
+
if not pause.get("paused"):
|
|
90
|
+
return None
|
|
91
|
+
status = answer["job"]["statusMessage"]
|
|
92
|
+
rest = status[len(QUOTA_EXHAUSTED):]
|
|
93
|
+
if "resetsAt" in pause:
|
|
94
|
+
rest = rest[len(" until ") + len(pause["resetsAt"]):]
|
|
95
|
+
if not rest.startswith(": "):
|
|
96
|
+
return None
|
|
97
|
+
match = _WAITING.match(rest[2:])
|
|
98
|
+
if not match or utc_time(match.group(2)) is None:
|
|
99
|
+
return None
|
|
100
|
+
waiting: Dict[str, Any] = {"reason": match.group(1), "since": match.group(2)}
|
|
101
|
+
if "resetsAt" in pause:
|
|
102
|
+
waiting["until"] = pause["resetsAt"]
|
|
103
|
+
if match.group(3) is not None:
|
|
104
|
+
waiting["message"] = match.group(3)
|
|
105
|
+
return waiting
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def hold_job(jobs: Any, job_id: str, reason: str, since: str, until: Optional[str] = None,
|
|
109
|
+
message: Optional[str] = None) -> Dict[str, Any]:
|
|
110
|
+
"""Put one job of a fabric_interop JobStore on hold: working, its statusMessage `waiting_message(...)`, its
|
|
111
|
+
pollIntervalMs reaching `until` (JobStore.pause). Returns the job."""
|
|
112
|
+
text = waiting_message(reason, since, until, message)[len(QUOTA_EXHAUSTED):]
|
|
113
|
+
if until is not None:
|
|
114
|
+
text = text[len(" until ") + len(until):]
|
|
115
|
+
return jobs.pause(job_id, resets_at=until, text=text[2:])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def hold_sentence(caller: str, work: str, reason: str, until: Optional[str]) -> str:
|
|
119
|
+
"""The push's one sentence: who, what, why, when it frees by itself, and the question."""
|
|
120
|
+
frees = ("it frees by itself at %s" % until) if until is not None else (
|
|
121
|
+
"it frees by itself when a running job ends" if reason in ("concurrency", "caller-share")
|
|
122
|
+
else "it does not free by itself")
|
|
123
|
+
return "%s for %s is held by %s; %s. Raise the limit for it?" % (work, caller, REASON_WORDS[reason], frees)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _text(value: Any, name: str, high: int = 200) -> str:
|
|
127
|
+
if not isinstance(value, str) or not value.strip() or len(value) > high:
|
|
128
|
+
raise ServiceError("%s is a text of 1 to %d characters." % (name, high))
|
|
129
|
+
if fabric_secrets.secret_value(value):
|
|
130
|
+
raise ServiceError("%s looks like a credential; a hold names callers and work, never a secret." % name)
|
|
131
|
+
return " ".join(value.split())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class HoldBook:
|
|
135
|
+
"""The holds a service's limits put on work and the operator's lifts, in one file (FAA-298).
|
|
136
|
+
|
|
137
|
+
`events`, when given, is the service's event log (an object with `append(kind, level, text, subject=..., **extra)`,
|
|
138
|
+
such as fabric_events.JsonlEventLog): the push and the lift record are written there. Without it they are returned
|
|
139
|
+
for the service to write."""
|
|
140
|
+
|
|
141
|
+
def __init__(self, path: Path, events: Any = None, clock: Callable[[], str] = now_iso):
|
|
142
|
+
self.path = Path(path)
|
|
143
|
+
self.events = events
|
|
144
|
+
self.clock = clock
|
|
145
|
+
self._lock = store_lock(self.path)
|
|
146
|
+
|
|
147
|
+
# --- the file -------------------------------------------------------------------------
|
|
148
|
+
def _load(self) -> Dict[str, Any]:
|
|
149
|
+
if not self.path.exists():
|
|
150
|
+
return {"holds": [], "lifts": []}
|
|
151
|
+
try:
|
|
152
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
153
|
+
except ValueError:
|
|
154
|
+
raise ServiceError("The hold book %s is not JSON; it is kept as it is." % self.path.name) from None
|
|
155
|
+
if not isinstance(data, dict) or not isinstance(data.get("holds"), list) or not isinstance(data.get("lifts"), list):
|
|
156
|
+
raise ServiceError("The hold book %s is not {holds, lifts}; it is kept as it is." % self.path.name)
|
|
157
|
+
return data
|
|
158
|
+
|
|
159
|
+
def _save(self, data: Dict[str, Any]) -> None:
|
|
160
|
+
atomic_write(self.path, (json.dumps(data, ensure_ascii=False, indent=1) + "\n").encode(), 0o600)
|
|
161
|
+
|
|
162
|
+
def _now(self, now: Optional[str]) -> str:
|
|
163
|
+
return _instant(now if now is not None else self.clock(), "now")
|
|
164
|
+
|
|
165
|
+
def _emit(self, kind: str, level: str, text: str, subject: Dict[str, str], notify: bool) -> Dict[str, Any]:
|
|
166
|
+
if self.events is not None:
|
|
167
|
+
return self.events.append(kind, level, text, subject=subject, notify=notify)
|
|
168
|
+
out: Dict[str, Any] = {"kind": kind, "level": level, "text": text, "subject": subject}
|
|
169
|
+
if notify:
|
|
170
|
+
out["notify"] = True
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _expire(data: Dict[str, Any], at: int) -> None:
|
|
175
|
+
for hold in data["holds"]:
|
|
176
|
+
if hold.get("closed") is None and hold.get("until") is not None and utc_time(hold["until"]) <= at:
|
|
177
|
+
hold["closed"], hold["closedAt"] = "expired", hold["until"]
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _live(lift: Dict[str, Any], at: int) -> bool:
|
|
181
|
+
if lift.get("from") is not None and utc_time(lift["from"]) > at:
|
|
182
|
+
return False
|
|
183
|
+
if lift.get("until") is not None and utc_time(lift["until"]) <= at:
|
|
184
|
+
return False
|
|
185
|
+
return lift.get("left", 1) > 0
|
|
186
|
+
|
|
187
|
+
# --- holds ----------------------------------------------------------------------------
|
|
188
|
+
def hold(self, *, caller: str, work: str, reason: str, until: Optional[str] = None, message: Optional[str] = None,
|
|
189
|
+
job_id: Optional[str] = None, now: Optional[str] = None) -> Dict[str, Any]:
|
|
190
|
+
"""Hold `work` for `caller` by `reason`. Returns {hold, statusMessage, push}: `push` is the notify event when this
|
|
191
|
+
call opened the hold, None when the work joined an open one (one push per hold)."""
|
|
192
|
+
caller, work = _text(caller, "caller"), _text(work, "work")
|
|
193
|
+
if reason not in HOLD_REASONS:
|
|
194
|
+
raise ServiceError("A hold's reason is one of %s." % ", ".join(HOLD_REASONS))
|
|
195
|
+
at = self._now(now)
|
|
196
|
+
if until is not None and utc_time(_instant(until, "until")) <= utc_time(at):
|
|
197
|
+
raise ServiceError("until %s has passed: the limit no longer holds the work." % until)
|
|
198
|
+
with self._lock:
|
|
199
|
+
data = self._load()
|
|
200
|
+
self._expire(data, utc_time(at))
|
|
201
|
+
hold = next((h for h in data["holds"] if h.get("closed") is None and h["reason"] == reason
|
|
202
|
+
and h["caller"] == caller), None)
|
|
203
|
+
opened = hold is None
|
|
204
|
+
if opened:
|
|
205
|
+
hold = {"id": uuid.uuid4().hex, "reason": reason, "caller": caller, "work": work, "since": at,
|
|
206
|
+
"until": until, "jobs": [], "closed": None}
|
|
207
|
+
data["holds"].append(hold)
|
|
208
|
+
if job_id is not None and job_id not in hold["jobs"]:
|
|
209
|
+
hold["jobs"].append(str(job_id))
|
|
210
|
+
self._save(data)
|
|
211
|
+
status = waiting_message(reason, hold["since"], hold["until"], message)
|
|
212
|
+
push = None
|
|
213
|
+
if opened:
|
|
214
|
+
push = self._emit(HOLD_EVENT_KIND, "notice", hold_sentence(caller, work, reason, until),
|
|
215
|
+
{"type": "limit", "id": "%s:%s" % (reason, caller)[:200], "label": REASON_WORDS[reason]}, True)
|
|
216
|
+
return {"hold": dict(hold), "statusMessage": status, "push": push}
|
|
217
|
+
|
|
218
|
+
def release(self, *, reason: str, caller: str, now: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
219
|
+
"""Close the open hold of (reason, caller): the limit freed the work. The next hold pushes again."""
|
|
220
|
+
at = self._now(now)
|
|
221
|
+
with self._lock:
|
|
222
|
+
data = self._load()
|
|
223
|
+
hold = next((h for h in data["holds"] if h.get("closed") is None and h["reason"] == reason
|
|
224
|
+
and h["caller"] == caller), None)
|
|
225
|
+
if hold is None:
|
|
226
|
+
return None
|
|
227
|
+
hold["closed"], hold["closedAt"] = "released", at
|
|
228
|
+
self._save(data)
|
|
229
|
+
return dict(hold)
|
|
230
|
+
|
|
231
|
+
def open_holds(self, now: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
232
|
+
data = self._load()
|
|
233
|
+
self._expire(data, utc_time(self._now(now)))
|
|
234
|
+
return [dict(h) for h in data["holds"] if h.get("closed") is None]
|
|
235
|
+
|
|
236
|
+
# --- lifts ----------------------------------------------------------------------------
|
|
237
|
+
def lift(self, *, reason: str, decision: Dict[str, Any], caller: Optional[str] = None, job_id: Optional[str] = None,
|
|
238
|
+
jobs: Optional[int] = None, usd: Optional[float] = None, limit: Optional[int] = None,
|
|
239
|
+
until: Optional[str] = None, now: Optional[str] = None) -> Dict[str, Any]:
|
|
240
|
+
"""Record the operator's answer as a scoped lift: exactly one of `jobs` (that many more jobs), `usd` (that much
|
|
241
|
+
more money) or `limit` (the limit raised to that value, which needs `until`, the window's end). `decision` is
|
|
242
|
+
the operator's words in decision.schema.json's shape with action `limit.lift`. The open hold it covers closes
|
|
243
|
+
as lifted. Returns the lift."""
|
|
244
|
+
if reason not in HOLD_REASONS:
|
|
245
|
+
raise ServiceError("A lift's reason is one of %s." % ", ".join(HOLD_REASONS))
|
|
246
|
+
if not decision_shape(decision) or decision.get("action") != LIFT_ACTION:
|
|
247
|
+
raise ServiceError("A lift carries the operator's decision (decision.schema.json, action limit.lift, their "
|
|
248
|
+
"words verbatim): fabric_actions.operator_decision('limit.lift', words).")
|
|
249
|
+
sizes = {"jobs": jobs, "usd": usd, "limit": limit}
|
|
250
|
+
given = [name for name in _SIZES if sizes[name] is not None]
|
|
251
|
+
if len(given) != 1:
|
|
252
|
+
raise ServiceError("A lift is one of jobs, usd or limit.")
|
|
253
|
+
size = sizes[given[0]]
|
|
254
|
+
if isinstance(size, bool) or not isinstance(size, (int, float)) or size <= 0 or (
|
|
255
|
+
given[0] != "usd" and int(size) != size):
|
|
256
|
+
raise ServiceError("A lift's %s is a positive %s." % (given[0], "number" if given[0] == "usd" else "integer"))
|
|
257
|
+
at = self._now(now)
|
|
258
|
+
if until is not None and utc_time(_instant(until, "until")) <= utc_time(at):
|
|
259
|
+
raise ServiceError("A lift's window ends after now.")
|
|
260
|
+
if given[0] == "limit" and until is None:
|
|
261
|
+
raise ServiceError("A raised limit needs its window's end (until): it falls back when the window ends.")
|
|
262
|
+
record: Dict[str, Any] = {"id": uuid.uuid4().hex, "reason": reason, "caller": None if caller is None else _text(caller, "caller"),
|
|
263
|
+
"job": None if job_id is None else str(job_id), "from": at, "until": until,
|
|
264
|
+
given[0]: size, "decision": dict(decision), "at": at}
|
|
265
|
+
if given[0] != "limit":
|
|
266
|
+
record["left"] = size
|
|
267
|
+
with self._lock:
|
|
268
|
+
data = self._load()
|
|
269
|
+
self._expire(data, utc_time(at))
|
|
270
|
+
for hold in data["holds"]:
|
|
271
|
+
if hold.get("closed") is None and hold["reason"] == reason and record["caller"] in (None, hold["caller"]) and (
|
|
272
|
+
job_id is None or str(job_id) in hold["jobs"]):
|
|
273
|
+
hold["closed"], hold["closedAt"], hold["lift"] = "lifted", at, record["id"]
|
|
274
|
+
data["lifts"].append(record)
|
|
275
|
+
self._save(data)
|
|
276
|
+
scope = ("for %s" % record["caller"]) if record["caller"] else "for every caller"
|
|
277
|
+
amount = {"jobs": "%s more job(s)", "usd": "USD %s more", "limit": "the limit at %s"}[given[0]] % size
|
|
278
|
+
self._emit(LIFT_EVENT_KIND, "info", "The operator lifted %s %s: %s%s." % (
|
|
279
|
+
REASON_WORDS[reason], scope, amount, (" until %s" % until) if until else ""),
|
|
280
|
+
{"type": "limit", "id": "%s:%s" % (reason, record["caller"] or "*"), "label": REASON_WORDS[reason]}, False)
|
|
281
|
+
return dict(record)
|
|
282
|
+
|
|
283
|
+
def _matching(self, data: Dict[str, Any], reason: str, caller: Optional[str], job_id: Optional[str], at: int,
|
|
284
|
+
kind: str) -> List[Dict[str, Any]]:
|
|
285
|
+
return [l for l in data["lifts"] if l["reason"] == reason and kind in l and self._live(l, at)
|
|
286
|
+
and l.get("caller") in (None, caller) and l.get("job") in (None, job_id)]
|
|
287
|
+
|
|
288
|
+
def admit(self, *, reason: str, caller: str, job_id: Optional[str] = None, cost_usd: Optional[float] = None,
|
|
289
|
+
now: Optional[str] = None) -> Dict[str, Any]:
|
|
290
|
+
"""Whether a lift lets work past the limit that would hold it: {admitted: True, lift} spends one job of a jobs
|
|
291
|
+
lift, or `cost_usd` of a money lift (the narrowest scope first: a job's, a caller's, every caller's), else
|
|
292
|
+
{admitted: False}. A raised limit is read with `limit`, not spent here."""
|
|
293
|
+
at = utc_time(self._now(now))
|
|
294
|
+
with self._lock:
|
|
295
|
+
data = self._load()
|
|
296
|
+
kind = "usd" if cost_usd is not None else "jobs"
|
|
297
|
+
for lift in sorted(self._matching(data, reason, caller, None if job_id is None else str(job_id), at, kind),
|
|
298
|
+
key=lambda l: (l.get("job") is None, l.get("caller") is None)):
|
|
299
|
+
need = cost_usd if kind == "usd" else 1
|
|
300
|
+
if lift["left"] >= need:
|
|
301
|
+
lift["left"] = round(lift["left"] - need, 6) if kind == "usd" else lift["left"] - 1
|
|
302
|
+
self._save(data)
|
|
303
|
+
return {"admitted": True, "lift": lift["id"], "left": lift["left"]}
|
|
304
|
+
return {"admitted": False}
|
|
305
|
+
|
|
306
|
+
def limit(self, *, reason: str, base: float, caller: Optional[str] = None, now: Optional[str] = None) -> float:
|
|
307
|
+
"""The limit in force: `base`, or the highest live raised limit for (reason, caller); `base` again once the
|
|
308
|
+
window ends."""
|
|
309
|
+
at = utc_time(self._now(now))
|
|
310
|
+
raised = [l["limit"] for l in self._matching(self._load(), reason, caller, None, at, "limit")]
|
|
311
|
+
return max([base] + raised)
|
|
312
|
+
|
|
313
|
+
def lifts(self, now: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
314
|
+
"""Every lift with `live`: whether it still applies now."""
|
|
315
|
+
at = utc_time(self._now(now))
|
|
316
|
+
return [dict(l, live=self._live(l, at)) for l in self._load()["lifts"]]
|
|
@@ -28,6 +28,7 @@ The transport is injected in tests: `transport(method, url, headers, body, timeo
|
|
|
28
28
|
`(status, headers, body_bytes)` or raises `OSError` (a connection error, `TimeoutError` included) or `HttpError`.
|
|
29
29
|
Standard library only. Part of the service kit (FAA-65, layer L4).
|
|
30
30
|
"""
|
|
31
|
+
# Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
|
|
31
32
|
from __future__ import annotations
|
|
32
33
|
|
|
33
34
|
import datetime as _dt
|
|
@@ -64,7 +65,7 @@ class HttpError(Exception):
|
|
|
64
65
|
refused URL or a body over the cap; `url` is redacted; `body` holds at most 2048 bytes of an answer's body."""
|
|
65
66
|
|
|
66
67
|
def __init__(self, message: str, *, status: Optional[int] = None, url: str = "", attempts: int = 0,
|
|
67
|
-
retry_after_s: Optional[float] = None, body: bytes = b"", kind: str = "http"):
|
|
68
|
+
retry_after_s: Optional[float] = None, body: bytes = b"", kind: str = "http", reason: Optional[str] = None):
|
|
68
69
|
super().__init__(message)
|
|
69
70
|
self.status = status
|
|
70
71
|
self.url = url
|
|
@@ -72,6 +73,9 @@ class HttpError(Exception):
|
|
|
72
73
|
self.retry_after_s = retry_after_s
|
|
73
74
|
self.body = bytes(body[:2048])
|
|
74
75
|
self.kind = kind # http (an answer), connection, timeout, too-large, refused, foreign-user
|
|
76
|
+
#: For foreign-user, why (FAA-272): `table-restricted` (this process sees no other process's sockets, which
|
|
77
|
+
#: never clears), the owner's kind (`another-account`, `system`) or the check's reason (`no-row`).
|
|
78
|
+
self.reason = reason
|
|
75
79
|
|
|
76
80
|
|
|
77
81
|
class Response:
|
|
@@ -357,13 +361,43 @@ class _CheckedHTTPConnection(_po.CheckedHTTPConnection):
|
|
|
357
361
|
self.vouch_pid = None # a loopback URL is never a process this caller started (FAA-267)
|
|
358
362
|
|
|
359
363
|
|
|
364
|
+
def _peer_is_loopback(sock: Any) -> bool:
|
|
365
|
+
"""Whether a connected socket reached this computer's loopback, by the address it connected to, not by the URL's
|
|
366
|
+
spelling: a name the hosts file maps to 127.0.0.1, `0.0.0.0`, `::ffff:127.0.0.1` and a proxy on loopback all
|
|
367
|
+
arrive here (0.74.0 unit readings 7, 8 and 11; FAA-260)."""
|
|
368
|
+
try:
|
|
369
|
+
host = str(sock.getpeername()[0]).split("%", 1)[0]
|
|
370
|
+
address = ipaddress.ip_address(host)
|
|
371
|
+
except (OSError, ValueError, TypeError, IndexError, AttributeError):
|
|
372
|
+
return False
|
|
373
|
+
mapped = getattr(address, "ipv4_mapped", None)
|
|
374
|
+
return bool((mapped or address).is_loopback or address.is_unspecified)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class _PlainHTTPConnection(http.client.HTTPConnection):
|
|
378
|
+
"""Plain http to a host not spelled as loopback (`insecure=True`) or to a proxy from the environment: when the
|
|
379
|
+
connected socket's peer is a loopback address, its server end is told as `_CheckedHTTPConnection` tells it, and
|
|
380
|
+
another account's, root's or an unknown owner is refused before a byte is sent (DEC-0154 HA-2, FAA-260)."""
|
|
381
|
+
|
|
382
|
+
def connect(self) -> None:
|
|
383
|
+
super().connect()
|
|
384
|
+
if not _peer_is_loopback(self.sock):
|
|
385
|
+
return
|
|
386
|
+
owner = _po.server_end(self.sock)
|
|
387
|
+
port = self.sock.getpeername()[1]
|
|
388
|
+
refused = _po.refusal(owner, port, None)
|
|
389
|
+
if refused is not None:
|
|
390
|
+
self.close()
|
|
391
|
+
raise _po.ListenerRefused(refused[0], "%s; nothing was sent" % refused[1], owner)
|
|
392
|
+
|
|
393
|
+
|
|
360
394
|
def _opener(watchdog: _Watchdog, direct: bool = False) -> urllib.request.OpenerDirector:
|
|
361
395
|
"""`direct`: a loopback URL never goes through a proxy from the environment (`http_proxy`), so the connection the
|
|
362
396
|
owner check reads is the one to the URL's own port (DEC-0154 HA-2), and a bearer never reaches a proxy."""
|
|
363
397
|
class Http(urllib.request.HTTPHandler):
|
|
364
398
|
def http_open(self, req):
|
|
365
399
|
local = _is_loopback((urllib.parse.urlsplit(req.full_url).hostname or "").lower())
|
|
366
|
-
return self.do_open(_watched(_CheckedHTTPConnection if local else
|
|
400
|
+
return self.do_open(_watched(_CheckedHTTPConnection if local else _PlainHTTPConnection, watchdog), req)
|
|
367
401
|
|
|
368
402
|
class Https(urllib.request.HTTPSHandler):
|
|
369
403
|
def https_open(self, req):
|
|
@@ -385,7 +419,10 @@ def urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optio
|
|
|
385
419
|
return _send(req, timeout_s, max_bytes, end, watchdog)
|
|
386
420
|
except _po.ListenerRefused as exc:
|
|
387
421
|
where = "%s %s" % (method, redact_url(url))
|
|
388
|
-
|
|
422
|
+
owner = getattr(exc, "owner", None)
|
|
423
|
+
why = str(getattr(owner, "reason", None) or getattr(owner, "kind", None) or getattr(exc, "code", "") or "unknown")
|
|
424
|
+
reason = _po.TABLE_RESTRICTED if why.startswith(_po.TABLE_RESTRICTED) else why.split(":", 1)[0]
|
|
425
|
+
raise HttpError("%s: foreign-user: %s" % (where, exc), url=redact_url(url), kind="foreign-user", reason=reason) from None
|
|
389
426
|
except (OSError, http.client.HTTPException) as exc:
|
|
390
427
|
if watchdog.fired:
|
|
391
428
|
raise TimeoutError("the answer did not arrive within the deadline")
|
|
@@ -67,6 +67,20 @@ class HubError(Exception):
|
|
|
67
67
|
return "HubError(%r, %r)" % (self.code, self.message)
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def listener_refusal(exc: Any) -> HubError:
|
|
71
|
+
"""The HubError for a hub port the peer-owner check refused (DEC-0154 HA-2): code `foreign-user`, the pinned
|
|
72
|
+
contract's state name, with the owner's reason in the sentence and in `data.reason` (FAA-272): `table-restricted`
|
|
73
|
+
when this process's socket table hides other processes' sockets, which never clears, so it is not retryable;
|
|
74
|
+
otherwise the owner's kind or the check's reason (`another-account`, `no-row`), retryable as before."""
|
|
75
|
+
owner = getattr(exc, "owner", None)
|
|
76
|
+
why = (getattr(owner, "reason", None) or getattr(owner, "kind", None) or getattr(exc, "code", None) or "unknown")
|
|
77
|
+
reason = _po.TABLE_RESTRICTED if str(why).startswith(_po.TABLE_RESTRICTED) else str(why).split(":", 1)[0]
|
|
78
|
+
kind = getattr(owner, "kind", None)
|
|
79
|
+
shown = "%s: %s" % (kind, owner.reason) if kind and getattr(owner, "reason", None) else str(kind or why)
|
|
80
|
+
return HubError("foreign-user", "The hub's port is not held by this OS account's process (%s); nothing was sent." % shown,
|
|
81
|
+
reason != _po.TABLE_RESTRICTED, {"reason": reason})
|
|
82
|
+
|
|
83
|
+
|
|
70
84
|
# --- where Fabric is --------------------------------------------------------------------------------------------
|
|
71
85
|
|
|
72
86
|
def fabric_root(platform: str = sys.platform, env: Optional[Dict[str, str]] = None) -> Path:
|
|
@@ -233,8 +247,7 @@ def post_mcp(origin: str, body: Dict[str, Any], bearer: str, session: Optional[s
|
|
|
233
247
|
raw = resp.read(4 * 1024 * 1024)
|
|
234
248
|
got = {k.lower(): v for k, v in resp.getheaders()}
|
|
235
249
|
except _po.ListenerRefused as exc:
|
|
236
|
-
raise
|
|
237
|
-
% (exc.owner.kind if exc.owner else exc.code), True) from None
|
|
250
|
+
raise listener_refusal(exc) from None
|
|
238
251
|
except (OSError, http.client.HTTPException) as exc:
|
|
239
252
|
raise HubError("hub-unavailable", "Fabric's hub did not answer (%s)." % exc.__class__.__name__, True) from None
|
|
240
253
|
finally:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py
CHANGED
|
@@ -825,7 +825,7 @@ class McpToolServer:
|
|
|
825
825
|
gated = self._gate_action(action, "fabric.job.get", {"id": job_id, "inputResponses": {key: value}}, None, None,
|
|
826
826
|
headers, tier, meta, caller, mandate)
|
|
827
827
|
if not gated["ok"]:
|
|
828
|
-
return gated
|
|
828
|
+
return dict(gated, capability=private.get("capability")) # the one read names the fault (FAA-186)
|
|
829
829
|
records[key] = gated["record"]
|
|
830
830
|
return {"ok": True, "records": records}
|
|
831
831
|
|
|
@@ -933,7 +933,7 @@ class McpToolServer:
|
|
|
933
933
|
if not inputs["ok"] and "invalid" in inputs:
|
|
934
934
|
return self._error(rid, -32602, inputs["invalid"])
|
|
935
935
|
if not inputs["ok"]:
|
|
936
|
-
stored =
|
|
936
|
+
stored = inputs.get("capability") # read once, by _gate_inputs (FAA-186)
|
|
937
937
|
self._fault(stored if isinstance(stored, str) else name, inputs) # as the start heard it (FAA-179)
|
|
938
938
|
import fabric_delegation as _fd # noqa: E402 - DL-19
|
|
939
939
|
return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(inputs["refusal"], traceparent=span)}
|