@passioncode-ai/fabric-agent-adapter 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +126 -1
  2. package/README.md +25 -17
  3. package/bin/fabric-agent-adapter.js +6 -6
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +13 -13
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +1 -1
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -4
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +56 -45
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/demo.html +1 -1
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +3 -3
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +12 -12
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +4 -4
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +1 -1
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +4 -4
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +13 -11
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +11 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +6 -6
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +8 -6
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +26 -26
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +3 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +10 -10
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +13 -13
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +4 -4
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +3 -3
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +5 -5
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +7 -4
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -209
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +1 -1
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +3 -13
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2521
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +16 -181
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +22 -44
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3430
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +1 -1
  102. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +33 -33
  103. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +1 -1
  104. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  105. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +27 -27
  106. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +31 -31
  107. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +9 -9
  108. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +8 -8
  109. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
  110. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
@@ -1,3434 +1,302 @@
1
- #!/usr/bin/env python3
2
- """Reference kit for the fabric-service/0.1 local service extension.
1
+ """Reference kit for the fabric-service/0.1 local service extension: the facade.
3
2
 
4
- Standard library only, Python 3.9+. Copy this file into a service (it is one
5
- module on purpose) or import it by path. Every function implements one rule of
6
- the extension; the rule it implements is named in its docstring, so a service
7
- that calls these functions inherits the rule instead of re-deriving it.
8
-
9
- Normative source: fabric-agent-contract docs/specification/service.md (DEC-0015).
10
- """
3
+ Since 0.40.0 (FAA-65) the kit is a set of modules, and this file re-exports every name it defined at 0.39.1, so
4
+ ``import fabric_service as fs`` keeps working. Install the kit with ``fabric_kit.py install <dir>``: it copies this
5
+ facade with the modules it needs (``kit.json`` lists them). Standard library only, Python 3.9+.
11
6
 
7
+ The modules, lowest first: fabric_base, fabric_platform, fabric_paths, fabric_protocols, fabric_lock, fabric_usage,
8
+ fabric_backups, fabric_descriptor, fabric_auth, fabric_events, fabric_logs, fabric_supervisors."""
12
9
  from __future__ import annotations
13
10
 
14
- import contextlib
15
- import datetime as _dt
16
- import errno
17
- import hashlib
18
- import hmac
19
- import http.server
20
- import json
21
- import ntpath
22
- import os
23
- from pathlib import Path, PurePath
24
- import plistlib
25
- import posixpath
26
- import re
27
- import secrets
28
- import shutil
29
- import signal
30
- import socket
31
- import socketserver
32
- import stat
33
- import subprocess
34
- import sys
35
- import tempfile
36
- import threading
37
- import time
38
- import traceback
39
- from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
40
- import urllib.error
41
- import urllib.parse
42
- import urllib.request
43
-
44
- PROTOCOL = "fabric-service/0.1"
45
- EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/service/0.1"
46
- # The embedded dashboard declaration (fabric-dashboard/0.1, DEC-0036 ED-9), under the well-known
47
- # document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
48
- DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
49
- DASHBOARD_KIT = "fabric-dashboard-kit@1.1.0"
50
- REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
51
- EXIT_ALREADY_RUNNING = 75
52
- # Lifecycle rules: references/lifecycle.md, the rule for this kit's users, which implements the public
53
- # contract's service specification (Lifecycle). A launchd-supervised copy that finds the lock held backs
54
- # off in-process for this long before its exit 75 (LC-03, F8): under KeepAlive with ThrottleInterval 10
55
- # that is a few starts an hour instead of one every 10 s.
56
- SUPERVISOR_ENV = "FABRIC_SERVICE_SUPERVISOR"
57
- LOCK_WAIT_SUPERVISED_SECONDS = 300.0
58
- LOCK_BACKOFF_FIRST_SECONDS = 0.5
59
- LOCK_BACKOFF_MAX_SECONDS = 30.0
60
- # LC-01: SIGTERM reaches exit in at most 10 s with work in flight — drain 8 s, then hand over,
61
- # and a hard exit 2 s later if the hand-over itself hangs. ExitTimeOut sits above both.
62
- DRAIN_SECONDS = 8.0
63
- DRAIN_GRACE_SECONDS = 2.0
64
- EXIT_HARD_STOP = 70
65
- DEFAULT_EXIT_TIMEOUT = 15
66
- # LC-12: logs rotate by size, 5 x 5 MB by default.
67
- LOG_MAX_BYTES = 5 * 1024 * 1024
68
- LOG_BACKUPS = 5
69
- LOGIN_CODE_TTL_SECONDS = 120
70
- # DEC-0039 (service.md "The session's lifetime"): the longest a service may keep an operator session.
71
- OPERATOR_SESSION_IDLE_SECONDS = 12 * 60 * 60
72
- OPERATOR_SESSION_MAX_SECONDS = 7 * 24 * 60 * 60
73
- EVENTS_DEFAULT_LIMIT = 50
74
- EVENTS_MAX_LIMIT = 200
75
- LEVELS = ("info", "notice", "warning", "error")
76
- STATUSES = ("starting", "ready", "degraded", "stopping")
77
-
78
- _ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
79
- _INSTANCE = re.compile(r"^[a-z][a-z0-9-]{0,31}$")
80
- _KIND = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,5}$")
81
- _ORIGIN = re.compile(r"^http://127\.0\.0\.1:([0-9]{3,5})$")
82
- # DEC-0019: a remote placement — an online agent or dashboard — lives at an https DNS name.
83
- _REMOTE_ORIGIN = re.compile(r"^https://((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(?::([0-9]{1,5}))?$")
84
- _RESERVED_HOST = re.compile(r"(^|\.)(localhost|local|internal|home\.arpa|lan|localdomain)$")
85
- PLACEMENTS = ("local", "remote")
86
- _CODE = re.compile(r"^[A-Za-z0-9_-]{16,256}$")
87
- _TRACE_ID = re.compile(r"^(?!0{32}$)[0-9a-f]{32}$")
88
- _SPAN_ID = re.compile(r"^(?!0{16}$)[0-9a-f]{16}$")
89
- # DEC-0032: a local path is POSIX (`/…`, `~/…`) or Windows (`C:\…`, `C:/…`, `~\…`); a network
90
- # share (`\\server\…`, `\\?\…`) is never a local path.
91
- # DEC-0057: `//server/…` and `/\\server…` are shares on Windows too, so a leading slash is followed by neither.
92
- _LOCAL_PATH = re.compile(r"^(~[\\/]|/(?![\\/])|[A-Za-z]:[\\/])")
93
- # DEC-0032: the supervisor per system and the field that names its job.
94
- # DEC-0061: `node` (Project Observatory's node supervises its children, no supervisor file); DEC-0069: `person` (an app
95
- # a person installs and starts; its descriptor says it is installed, never that it runs).
96
- MANAGERS = ("launchd", "systemd", "task-scheduler", "node", "person", "none")
97
- #: Managers that write no launchd label or plist (DEC-0057, DEC-0061, DEC-0069).
98
- _NOT_LAUNCHD = ("systemd", "task-scheduler", "node", "person")
99
- _AUTH_HEADER = re.compile(r"[A-Za-z][A-Za-z0-9-]{0,63}")
100
- _UNIT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$")
101
- _TASK = re.compile(r"^\\[A-Za-z0-9 ._\\-]{1,254}$")
102
- WINDOWS = os.name == "nt"
103
- # Without O_BINARY a Windows descriptor is in text mode and every LF written becomes CRLF.
104
- _O_BINARY = getattr(os, "O_BINARY", 0)
105
-
106
-
107
- class ServiceError(Exception):
108
- """A rule of the extension was violated; the message is one readable sentence."""
109
-
110
-
111
- class AlreadyRunning(ServiceError):
112
- def __init__(self, holder_pid: Optional[int], lock_path: Path):
113
- self.holder_pid = holder_pid
114
- self.lock_path = lock_path
115
- # FAC-SEM-078: a copy that cannot learn the holder's pid says the holder is unknown.
116
- who = "process %d" % holder_pid if holder_pid else "an unknown process"
117
- super().__init__("Another copy is already running (%s holds %s)." % (who, lock_path))
118
-
119
-
120
- def now_iso() -> str:
121
- return _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
122
-
123
-
124
- def expand(path: str) -> Path:
125
- """Paths in a descriptor may start with ~/ (or ~\\ on Windows); everything else must be
126
- absolute, and a network share is never a local path (DEC-0032)."""
127
- if not _LOCAL_PATH.match(str(path)):
128
- raise ServiceError("Path %r must be absolute or start with ~/." % path)
129
- p = Path(os.path.expanduser(path))
130
- if not p.is_absolute():
131
- raise ServiceError("Path %r must be absolute or start with ~/." % path)
132
- return p
133
-
134
-
135
- # --- where things live -------------------------------------------------------
136
-
137
- def services_dir() -> Path:
138
- """Descriptor directory: FABRIC_SERVICES_DIR, else the per-user OS location."""
139
- override = os.environ.get("FABRIC_SERVICES_DIR")
140
- if override:
141
- return Path(override).expanduser()
142
- home = Path.home()
143
- if sys.platform == "darwin":
144
- return home / "Library/Application Support/ai.passioncode.fabric/services"
145
- if sys.platform == "win32":
146
- # DEC-0032: LOCALAPPDATA, not APPDATA, so token files never roam with the profile.
147
- return _local_app_data() / "passioncode-fabric" / "services"
148
- base = os.environ.get("XDG_DATA_HOME") or str(home / ".local/share")
149
- return Path(base) / "passioncode-fabric/services"
150
-
151
-
152
- def service_dirs(service_id: str) -> Dict[str, Path]:
153
- """State lives outside code: data+config, logs and cache in per-user OS directories."""
154
- _require_id(service_id)
155
- home = Path.home()
156
- if sys.platform == "darwin":
157
- return {
158
- "data": home / "Library/Application Support" / service_id,
159
- "logs": home / "Library/Logs" / service_id,
160
- "cache": home / "Library/Caches" / service_id,
161
- }
162
- if sys.platform == "win32":
163
- data = _local_app_data() / service_id
164
- return {"data": data, "logs": data / "Logs", "cache": data / "Cache"}
165
- data = Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share") / service_id
166
- state = Path(os.environ.get("XDG_STATE_HOME") or home / ".local/state") / service_id
167
- cache = Path(os.environ.get("XDG_CACHE_HOME") or home / ".cache") / service_id
168
- return {"data": data, "logs": state / "logs", "cache": cache}
169
-
170
-
171
- def _read_at(fd: int, n: int, offset: int) -> bytes:
172
- """`os.pread` where it exists; Windows has none, so seek and read (the descriptor's appends
173
- still go to the end: O_APPEND moves to it before every write)."""
174
- if hasattr(os, "pread"):
175
- return os.pread(fd, n, offset)
176
- os.lseek(fd, offset, os.SEEK_SET)
177
- return os.read(fd, n)
178
-
179
-
180
- def _local_app_data() -> Path:
181
- value = os.environ.get("LOCALAPPDATA")
182
- return Path(value) if value else Path.home() / "AppData" / "Local"
183
-
184
-
185
- def ensure_private_dir(path: Path) -> Path:
186
- path.mkdir(parents=True, exist_ok=True)
187
- if WINDOWS:
188
- _win_protect(path, folder=True)
189
- else:
190
- os.chmod(path, 0o700)
191
- return path
192
-
193
-
194
- def atomic_write(path: Path, data: bytes, mode: int = 0o600) -> None:
195
- """Temporary file in the same directory, fsync, rename: a reader never sees half a file."""
196
- path.parent.mkdir(parents=True, exist_ok=True)
197
- fd, tmp = tempfile.mkstemp(prefix="." + path.name + ".", dir=str(path.parent))
198
- try:
199
- if WINDOWS:
200
- # The file's ACL is its privacy (DEC-0032): set explicitly and protected — before the bytes go in — so
201
- # it does not depend on what the folder hands down. A rename keeps it.
202
- _win_protect(Path(tmp), folder=False)
203
- with os.fdopen(fd, "wb") as handle:
204
- handle.write(data)
205
- handle.flush()
206
- os.fsync(handle.fileno())
207
- if not WINDOWS:
208
- os.chmod(tmp, mode)
209
- os.replace(tmp, path)
210
- except BaseException:
211
- try:
212
- os.unlink(tmp)
213
- except FileNotFoundError:
214
- pass
215
- raise
216
-
217
-
218
- # --- one copy ----------------------------------------------------------------
219
-
220
- class InstanceLock:
221
- """Exclusive flock on <data>/service.lock, taken BEFORE any side effect.
222
-
223
- Binding a port is not a lock: a second copy that resumes jobs before its
224
- bind fails has already done damage. The lock is released by the kernel when
225
- the process dies, so a crash never leaves it stale.
226
- """
227
-
228
- def __init__(self, data_dir: Path):
229
- self.path = Path(data_dir) / "service.lock"
230
- self._fd: Optional[int] = None
231
- self._name: Any = None
232
-
233
- #: Windows locks a byte range, not the file: one byte far past the pid the file holds, so the
234
- #: holder may rewrite the pid and another process may still read it (locking past the end of
235
- #: a file is allowed there).
236
- WINDOWS_LOCK_OFFSET = 1 << 30
237
-
238
- def acquire(self) -> "InstanceLock":
239
- ensure_private_dir(self.path.parent)
240
- fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT | _O_BINARY, 0o600)
241
- try:
242
- if WINDOWS:
243
- import msvcrt
244
-
245
- os.lseek(fd, self.WINDOWS_LOCK_OFFSET, os.SEEK_SET)
246
- msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
247
- os.lseek(fd, 0, os.SEEK_SET)
248
- else:
249
- import fcntl
250
-
251
- fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
252
- except OSError as exc:
253
- os.close(fd)
254
- if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES, getattr(errno, "EDEADLOCK", -1)):
255
- raise AlreadyRunning(_read_pid(self.path), self.path) from None
256
- raise
257
- try:
258
- self._name = _hold_lock_name(self.path)
259
- except BaseException:
260
- os.close(fd)
261
- raise
262
- os.ftruncate(fd, 0)
263
- os.write(fd, str(os.getpid()).encode())
264
- os.fsync(fd)
265
- self._fd = fd
266
- return self
267
-
268
- def release(self) -> None:
269
- if self._fd is not None:
270
- os.close(self._fd)
271
- self._fd = None
272
- if self._name is not None:
273
- _drop_lock_name(self._name)
274
- self._name = None
275
-
276
-
277
- # The Node kit cannot flock on Linux or lock a byte range on Windows, so it holds a name instead: an
278
- # abstract unix socket on Linux, a named pipe on Windows — exclusive, no file, gone with the process.
279
- # This kit holds the same name beside its file lock, so the two kits exclude each other everywhere
280
- # and the probe sees either one (`lock_name_held`).
281
-
282
- def lock_name(lock_path: Path, platform: str = sys.platform) -> Optional[str]:
283
- """The name the Node kit holds for this lock file; None where it holds none (macOS: O_EXLOCK)."""
284
- if platform.startswith("linux"):
285
- digest = hashlib.sha256(posixpath.abspath(str(lock_path)).encode()).hexdigest()[:32]
286
- return "\0fabric-service-" + digest
287
- if platform == "win32":
288
- # Windows paths compare without case: C:\Users and c:\users are one lock.
289
- digest = hashlib.sha256(ntpath.normcase(ntpath.abspath(str(lock_path))).encode()).hexdigest()[:32]
290
- return "\\\\.\\pipe\\fabric-service-" + digest
291
- return None
292
-
293
-
294
- def _hold_lock_name(lock_path: Path) -> Any:
295
- name = lock_name(lock_path)
296
- if name is None:
297
- return None
298
- if WINDOWS:
299
- handle = _create_first_pipe(name)
300
- if handle is None:
301
- raise AlreadyRunning(_read_pid(lock_path), lock_path)
302
- return handle
303
- sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
304
- try:
305
- sock.bind(name)
306
- except OSError as exc:
307
- sock.close()
308
- if exc.errno == errno.EADDRINUSE:
309
- raise AlreadyRunning(_read_pid(lock_path), lock_path) from None
310
- raise
311
- # Held by its descriptor, like the file lock: a socket object would close when collected.
312
- return sock.detach()
313
-
314
-
315
- def _drop_lock_name(held: Any) -> None:
316
- if WINDOWS:
317
- _close_handle(held)
318
- else:
319
- os.close(held)
320
-
321
-
322
- def lock_name_held(lock_path: Path) -> Optional[bool]:
323
- """True when some process holds the lock's name (a running service of either kit); None where there is no name
324
- to hold. It only looks — /proc/net/unix on Linux, WaitNamedPipeW on Windows — and never takes the name, so a
325
- probe cannot make a service that is starting at that moment fail."""
326
- name = lock_name(lock_path)
327
- if name is None:
328
- return None
329
- if WINDOWS:
330
- return _pipe_exists(name)
331
- try:
332
- with open("/proc/net/unix", encoding="utf-8", errors="replace") as table:
333
- return any(line.rstrip("\n").endswith(" @" + name[1:]) for line in table)
334
- except OSError:
335
- return None
336
-
337
-
338
- def _read_pid(path: Path) -> Optional[int]:
339
- try:
340
- text = path.read_text().strip()
341
- return int(text) if re.fullmatch(r"[0-9]{1,10}", text, re.ASCII) else None
342
- except OSError:
343
- return None
344
-
345
-
346
- #: The managers a supervisor marker may name (DEC-0057): the three systems' supervisors and Project Observatory's node.
347
- SUPERVISORS = ("launchd", "systemd", "task-scheduler", "node")
348
- #: The argument form of the marker, for a supervisor whose start carries no environment (a Scheduled Task's action).
349
- SUPERVISOR_ARG = "--fabric-supervisor"
350
-
351
-
352
- def _marker_arguments(argv: Sequence[Any]) -> List[str]:
353
- """The marker values an argument vector carries after the executable and before ``--``. A bare
354
- ``--fabric-supervisor`` followed by nothing, by ``--`` or by another ``--…`` flag is a marker with an empty value
355
- and consumes nothing."""
356
- values: List[str] = []
357
- index = 1
358
- while index < len(argv):
359
- item = argv[index]
360
- if item == "--":
361
- break
362
- if item == SUPERVISOR_ARG:
363
- following = argv[index + 1] if index + 1 < len(argv) else None
364
- if isinstance(following, str) and not following.startswith("--"):
365
- values.append(following)
366
- index += 1
367
- else:
368
- values.append("")
369
- elif isinstance(item, str) and item.startswith(SUPERVISOR_ARG + "="):
370
- values.append(item[len(SUPERVISOR_ARG) + 1:])
371
- index += 1
372
- return values
373
-
374
-
375
- def supervisor_marker(env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> Dict[str, Any]:
376
- """How this copy was started (DEC-0057), from ``FABRIC_SERVICE_SUPERVISOR=<manager>`` and
377
- ``--fabric-supervisor <manager>`` / ``--fabric-supervisor=<manager>`` (``env`` and ``argv`` default to this process's):
378
-
379
- - ``{"kind": "supervised", "manager", "forms"}`` — every marker present names the same manager of SUPERVISORS;
380
- - ``{"kind": "conflict", "values", "forms"}`` — the markers disagree: the copy is unsupervised and exits 75 at once,
381
- before it takes the lock (an empty or bare argument beside a variable disagrees with it);
382
- - ``{"kind": "manual", "forms", "unreadable"?}`` — no marker (only an empty variable counts as none), or one that
383
- names no manager.
384
-
385
- A copy never infers supervision from its parent process. The contract's ``supervisorMarker``."""
386
- env = os.environ if env is None else env
387
- argv = sys.argv if argv is None else argv
388
- variable = env.get(SUPERVISOR_ENV) if isinstance(env, dict) or hasattr(env, "get") else None
389
- from_env = [variable] if isinstance(variable, str) and variable != "" else []
390
- from_args = _marker_arguments(list(argv) if isinstance(argv, (list, tuple)) else [])
391
- forms = (["environment"] if from_env else []) + (["argument"] if from_args else [])
392
- values: List[str] = []
393
- for value in from_env + from_args:
394
- if value not in values:
395
- values.append(value)
396
- if len(values) > 1:
397
- return {"kind": "conflict", "values": values, "forms": forms}
398
- if not values:
399
- return {"kind": "manual", "forms": forms}
400
- if values[0] in SUPERVISORS:
401
- return {"kind": "supervised", "manager": values[0], "forms": forms}
402
- return {"kind": "manual", "unreadable": values[0], "forms": forms}
403
-
404
-
405
- def lock_wait_budget_seconds(marker: Dict[str, Any]) -> float:
406
- """How long a copy started so may wait for the lock: 300 s when supervised, else not at all."""
407
- return LOCK_WAIT_SUPERVISED_SECONDS if marker.get("kind") == "supervised" else 0.0
408
-
409
-
410
- def marker_conflict_message(marker: Dict[str, Any]) -> str:
411
- """The one sentence a copy whose markers disagree exits with, naming the markers present."""
412
- shown = ", ".join(repr(v) for v in marker["values"])
413
- if marker["forms"] == ["environment", "argument"]:
414
- return "%s and %s disagree (%s); not starting unsupervised." % (SUPERVISOR_ENV, SUPERVISOR_ARG, shown)
415
- return "%s is given with different values (%s); not starting unsupervised." % (SUPERVISOR_ARG, shown)
416
-
417
-
418
- def supervised(env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> bool:
419
- """True when a supervisor that restarts on exit started this process and said so with the marker
420
- (``launchd_plist`` and ``systemd_unit`` set the variable, ``task_xml`` writes the argument, a node sets either)."""
421
- return supervisor_marker(env, argv)["kind"] == "supervised"
422
-
423
-
424
- def supervised_by_launchd(env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> bool:
425
- """True when launchd started this process from a plist written by ``launchd_plist``.
426
-
427
- The plist says so explicitly (``FABRIC_SERVICE_SUPERVISOR=launchd``); the parent process or
428
- ``XPC_SERVICE_NAME`` is not evidence — a terminal inside an app carries one too."""
429
- return supervisor_marker(env, argv).get("manager") == "launchd"
430
-
431
-
432
- def add_supervisor_argument(parser: Any) -> Any:
433
- """Let a service's own argparse parser accept the marker in both spellings, and a bare one, and ignore it
434
- (DEC-0057: argparse exits 2 on an unknown flag). The kit reads the marker from ``sys.argv`` itself."""
435
- import argparse
436
- parser.add_argument(SUPERVISOR_ARG, nargs="?", const="", default=None, help=argparse.SUPPRESS)
437
- return parser
438
-
439
-
440
- def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *,
441
- sleep: Callable[[float], None] = time.sleep,
442
- clock: Callable[[], float] = time.monotonic,
443
- env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> InstanceLock:
444
- """Take the lock or exit 75 with one sentence naming the holder (DEC-0057, FAC-SEM-078).
445
-
446
- Markers that disagree exit 75 at once, before the lock, naming them. Started by hand (no
447
- marker), a held lock exits 75 at once. Under a supervisor that restarts on exit an immediate
448
- exit is a respawn every few seconds, forever; so a supervised copy first backs off in-process
449
- (0.5 s doubling to 30 s, ``LOCK_WAIT_SUPERVISED_SECONDS`` in all), takes over if the holder
450
- leaves, and exits 75 only when the wait runs out. Nothing has been touched while it waits.
451
- ``wait_seconds`` overrides the choice (0 = never wait); ``env`` and ``argv`` default to this process's."""
452
- marker = supervisor_marker(env, argv)
453
- if marker["kind"] == "conflict":
454
- print(marker_conflict_message(marker), file=sys.stderr)
455
- raise SystemExit(EXIT_ALREADY_RUNNING)
456
- if wait_seconds is None:
457
- wait_seconds = lock_wait_budget_seconds(marker)
458
- deadline = clock() + max(0.0, wait_seconds)
459
- delay = LOCK_BACKOFF_FIRST_SECONDS
460
- announced = False
461
- while True:
462
- try:
463
- return InstanceLock(data_dir).acquire()
464
- except AlreadyRunning as exc:
465
- remaining = deadline - clock()
466
- if remaining <= 0:
467
- print(str(exc), file=sys.stderr)
468
- raise SystemExit(EXIT_ALREADY_RUNNING)
469
- if not announced:
470
- print("%s Waiting up to %.0f s for it to exit." % (exc, remaining), file=sys.stderr, flush=True)
471
- announced = True
472
- sleep(min(delay, remaining))
473
- delay = min(delay * 2, LOCK_BACKOFF_MAX_SECONDS)
474
-
475
-
476
- # --- Windows ACLs (DEC-0032) ---------------------------------------------------
477
-
478
- #: The trustees a token file may grant anything to besides the current user: LocalSystem and the
479
- #: Administrators group — the same trust as root reading a 0600 file on POSIX.
480
- WINDOWS_TRUSTED = ("S-1-5-18", "S-1-5-32-544")
481
-
482
-
483
- def token_file_problem(path: Path, file: Dict[str, Any], user: str) -> Optional[str]:
484
- """windows_token_acl_problem as one sentence naming the file and the refusal, or None."""
485
- problem = windows_token_acl_problem(file, user)
486
- return None if problem is None else "Token file %s refused: %s." % (path, problem["message"])
487
-
488
-
489
- SYSTEM_SID = "S-1-5-18"
490
- ADMINISTRATORS_SID = "S-1-5-32-544"
491
- #: A string SID: revision 1, an identifier authority and up to 15 sub-authorities of 32 bits each.
492
- _SID_TEXT = re.compile(r"S-1-[0-9]{1,15}(?:-[0-9]{1,10}){0,15}", re.IGNORECASE | re.ASCII)
493
-
494
-
495
- def _sid(value: Any) -> Optional[str]:
496
- """A readable SID in upper case (SIDs compare case-insensitively on their S), or None."""
497
- if not isinstance(value, str) or not _SID_TEXT.fullmatch(value):
498
- return None
499
- return value.upper() if all(int(part) <= 0xFFFFFFFF for part in value.split("-")[3:]) else None
500
-
501
-
502
- def windows_token_acl_problem(file: Any, current_user_sid: Any) -> Optional[Dict[str, Any]]:
503
- """service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057, FAC-SEM-079) on what a reader has read:
504
- ``file`` is ``{isRegularFile, isReparsePoint, realPathInProfile, ownerSid, aces: [{type, sid, mask}]}``, the mask
505
- the ACE's unsigned 32-bit access mask. The first refusal ``{problem, at, sid?, message}``, or None when the file may
506
- be read: a regular file, not a reparse point, inside the profile; owned by the user, SYSTEM or Administrators; every
507
- allow ACE with a non-zero mask naming one of those three; deny ACEs ignored; any other ACE type refused; anything
508
- unreadable refused. The contract's ``windowsTokenAclProblem``; a refusal names the SID, never the contents."""
509
- def refuse(problem: str, at: str, message: str, sid: Optional[str] = None) -> Dict[str, Any]:
510
- out = {"problem": problem, "at": at, "message": message}
511
- if sid is not None:
512
- out["sid"] = sid
513
- return out
514
- user = _sid(current_user_sid)
515
- if user is None:
516
- return refuse("unreadable", "/currentUserSid", "the current user's SID is unreadable")
517
- if not isinstance(file, dict):
518
- return refuse("unreadable", "/file", "the token file's metadata is unreadable")
519
- for flag in ("isRegularFile", "isReparsePoint", "realPathInProfile"):
520
- if not isinstance(file.get(flag), bool):
521
- return refuse("unreadable", "/file/" + flag, flag + " is unreadable")
522
- if file["isReparsePoint"]:
523
- return refuse("reparse-point", "/file/isReparsePoint", "the token file is a reparse point (a symlink or a junction)")
524
- if not file["isRegularFile"]:
525
- return refuse("not-regular-file", "/file/isRegularFile", "the token file is not a regular file")
526
- if not file["realPathInProfile"]:
527
- return refuse("outside-profile", "/file/realPathInProfile", "the token file's real path lies outside the user's profile")
528
- trusted = {user, SYSTEM_SID, ADMINISTRATORS_SID}
529
- owner = _sid(file.get("ownerSid"))
530
- if owner is None:
531
- return refuse("unreadable", "/file/ownerSid", "the owner SID is unreadable")
532
- if owner not in trusted:
533
- return refuse("owner", "/file/ownerSid", "the token file is owned by %s, not by the current user, SYSTEM or Administrators" % owner, owner)
534
- aces = file.get("aces")
535
- if not isinstance(aces, list):
536
- return refuse("unreadable", "/file/aces", "the ACL is unreadable")
537
- for index, ace in enumerate(aces):
538
- at = "/file/aces/%d" % index
539
- if not isinstance(ace, dict):
540
- return refuse("unreadable", at, "ACE %d is unreadable" % index)
541
- if not isinstance(ace.get("type"), str):
542
- return refuse("unreadable", at + "/type", "ACE %d has an unreadable type" % index)
543
- trustee = _sid(ace.get("sid"))
544
- if trustee is None:
545
- return refuse("unreadable", at + "/sid", "ACE %d names an unreadable SID" % index)
546
- mask = ace.get("mask")
547
- if (not isinstance(mask, (int, float)) or isinstance(mask, bool) or (isinstance(mask, float) and not mask.is_integer())
548
- or not 0 <= mask <= 0xFFFFFFFF):
549
- return refuse("unreadable", at + "/mask", "ACE %d has an unreadable access mask" % index)
550
- if ace["type"] == "deny":
551
- continue
552
- if ace["type"] != "allow":
553
- return refuse("ace-type", at + "/type", "ACE %d for %s is of a type the rule does not read" % (index, trustee), trustee)
554
- if mask == 0:
555
- continue
556
- if trustee not in trusted:
557
- return refuse("ace-sid", at + "/sid", "the token file grants access to %s; only the current user, SYSTEM and Administrators may hold any" % trustee, trustee)
558
- return None
559
-
560
-
561
- #: SDDL ACE types (MS-DTYP §2.5.1.1) as the rule's ACE type: allow and deny are read, every other type is refused.
562
- _SDDL_ACE_TYPES = {"A": "allow", "D": "deny", "OA": "allow-object", "OD": "deny-object", "XA": "allow-callback",
563
- "XD": "deny-callback", "ZA": "allow-object-callback"}
564
- #: SDDL access-right aliases (MS-DTYP §2.5.1.1, "ace_rights") and their masks.
565
- _SDDL_RIGHTS = {"GA": 0x10000000, "GR": 0x80000000, "GW": 0x40000000, "GX": 0x20000000, "RC": 0x20000, "SD": 0x10000,
566
- "WD": 0x40000, "WO": 0x80000, "RP": 0x10, "WP": 0x20, "CC": 0x1, "DC": 0x2, "LC": 0x4, "SW": 0x8,
567
- "LO": 0x80, "DT": 0x40, "CR": 0x100, "FA": 0x1F01FF, "FR": 0x120089, "FW": 0x120116, "FX": 0x1200A0,
568
- "KA": 0xF003F, "KR": 0x20019, "KX": 0x20019, "KW": 0x20006}
569
-
570
-
571
- def _sddl_ace_strings(dacl: str) -> List[str]:
572
- """The top-level parenthesized ACE strings of an SDDL DACL; a callback ACE's conditional expression may nest."""
573
- aces, depth, start = [], 0, 0
574
- for index, char in enumerate(dacl):
575
- if char == "(":
576
- if depth == 0:
577
- start = index + 1
578
- depth += 1
579
- elif char == ")" and depth:
580
- depth -= 1
581
- if depth == 0:
582
- aces.append(dacl[start:index])
583
- return aces
584
-
585
-
586
- def _sddl_mask(rights: str) -> Optional[int]:
587
- """An SDDL rights field as the unsigned 32-bit mask, or None when it cannot be read."""
588
- if re.fullmatch(r"0[xX][0-9a-fA-F]{1,8}", rights):
589
- return int(rights, 16)
590
- if len(rights) % 2:
591
- return None
592
- mask = 0
593
- for i in range(0, len(rights), 2):
594
- if rights[i:i + 2] not in _SDDL_RIGHTS:
595
- return None
596
- mask |= _SDDL_RIGHTS[rights[i:i + 2]]
597
- return mask
598
-
599
-
600
- def acl_from_sddl(sddl: str, canonical: Callable[[str], str] = lambda sid: sid) -> Tuple[str, List[Dict[str, Any]]]:
601
- """(owner SID, the ACEs that apply to the object itself as ``{type, sid, mask}``) from an SDDL owner-and-DACL
602
- string. ``canonical`` turns an SDDL alias (SY, BA, WD…) into its SID. A missing or NULL DACL grants everyone
603
- everything and is read as an allow ACE for Everyone (S-1-1-0); an inherit-only ACE (flag IO) applies to children
604
- only and is not read; an unreadable rights field gives mask None, which the rule refuses."""
605
- owner = sddl.split("O:", 1)[1].split("G:", 1)[0].split("D:", 1)[0] if "O:" in sddl else ""
606
- dacl = sddl.split("D:", 1)[1] if "D:" in sddl else ""
607
- owner_sid = canonical(owner) if owner else ""
608
- if not dacl or dacl.startswith("NO_ACCESS_CONTROL"):
609
- return owner_sid, [{"type": "allow", "sid": "S-1-1-0", "mask": 0x1F01FF}]
610
- aces = []
611
- for text in _sddl_ace_strings(dacl):
612
- fields = text.split(";", 6)
613
- if len(fields) < 6: # unreadable: the rule refuses it
614
- aces.append({"type": None, "sid": None, "mask": None})
615
- continue
616
- kind, flags, rights, trustee = fields[0], fields[1], fields[2], fields[5]
617
- if "IO" in [flags[i:i + 2] for i in range(0, len(flags), 2)]:
618
- continue
619
- aces.append({"type": _SDDL_ACE_TYPES.get(kind, kind), "sid": canonical(trustee), "mask": _sddl_mask(rights)})
620
- return owner_sid, aces
621
-
622
-
623
- if WINDOWS: # pragma: no cover - exercised by the Windows CI job
624
- import ctypes
625
- from ctypes import wintypes
626
-
627
- _advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
628
- _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
629
- _PVOID = ctypes.c_void_p
630
- _SE_FILE_OBJECT, _OWNER_INFO, _DACL_INFO, _PROTECTED_DACL = 1, 0x1, 0x4, 0x80000000
631
- _REPARSE_POINT = 0x400
632
- _advapi32.GetNamedSecurityInfoW.argtypes = [wintypes.LPCWSTR, ctypes.c_int, wintypes.DWORD] + [ctypes.POINTER(_PVOID)] * 5
633
- _advapi32.GetNamedSecurityInfoW.restype = wintypes.DWORD
634
- _advapi32.SetNamedSecurityInfoW.argtypes = [wintypes.LPWSTR, ctypes.c_int, wintypes.DWORD] + [_PVOID] * 4
635
- _advapi32.SetNamedSecurityInfoW.restype = wintypes.DWORD
636
- _advapi32.ConvertSecurityDescriptorToStringSecurityDescriptorW.argtypes = [
637
- _PVOID, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(wintypes.LPWSTR), ctypes.POINTER(wintypes.ULONG)]
638
- _advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [
639
- wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(_PVOID), ctypes.POINTER(wintypes.ULONG)]
640
- _advapi32.GetSecurityDescriptorDacl.argtypes = [_PVOID, ctypes.POINTER(wintypes.BOOL), ctypes.POINTER(_PVOID),
641
- ctypes.POINTER(wintypes.BOOL)]
642
- _advapi32.OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)]
643
- _advapi32.GetTokenInformation.argtypes = [wintypes.HANDLE, ctypes.c_int, _PVOID, wintypes.DWORD,
644
- ctypes.POINTER(wintypes.DWORD)]
645
- _advapi32.ConvertSidToStringSidW.argtypes = [_PVOID, ctypes.POINTER(wintypes.LPWSTR)]
646
- _advapi32.ConvertStringSidToSidW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(_PVOID)]
647
- _kernel32.GetCurrentProcess.restype = wintypes.HANDLE
648
- _kernel32.LocalFree.argtypes = [_PVOID]
649
- _kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
650
- _CANONICAL: Dict[str, str] = {}
651
-
652
- def _win_error(what: str, code: Optional[int] = None) -> OSError:
653
- code = ctypes.get_last_error() if code is None else code
654
- return OSError(code, "%s: %s" % (what, ctypes.FormatError(code).strip()))
655
-
656
- def _sid_text(sid: Any) -> str:
657
- text = wintypes.LPWSTR()
658
- if not _advapi32.ConvertSidToStringSidW(sid, ctypes.byref(text)):
659
- raise _win_error("ConvertSidToStringSidW")
660
- try:
661
- return text.value
662
- finally:
663
- _kernel32.LocalFree(text)
664
-
665
- def _canonical_sid(trustee: str) -> str:
666
- """SDDL writes well-known accounts by alias (SY, BA, LA, OW); compare SIDs."""
667
- if trustee not in _CANONICAL:
668
- sid = _PVOID()
669
- if _advapi32.ConvertStringSidToSidW(trustee, ctypes.byref(sid)):
670
- try:
671
- _CANONICAL[trustee] = _sid_text(sid)
672
- finally:
673
- _kernel32.LocalFree(sid)
674
- else:
675
- _CANONICAL[trustee] = trustee
676
- return _CANONICAL[trustee]
677
-
678
- def windows_user_sid() -> str:
679
- token = wintypes.HANDLE()
680
- if not _advapi32.OpenProcessToken(_kernel32.GetCurrentProcess(), 0x8, ctypes.byref(token)):
681
- raise _win_error("OpenProcessToken")
682
- try:
683
- size = wintypes.DWORD()
684
- _advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(size))
685
- buf = ctypes.create_string_buffer(size.value)
686
- if not _advapi32.GetTokenInformation(token, 1, buf, size, ctypes.byref(size)):
687
- raise _win_error("GetTokenInformation")
688
- return _sid_text(ctypes.cast(buf, ctypes.POINTER(_PVOID))[0])
689
- finally:
690
- _kernel32.CloseHandle(token)
691
-
692
- def _sddl(path: Path) -> str:
693
- sd = _PVOID()
694
- refs = [_PVOID() for _ in range(4)]
695
- code = _advapi32.GetNamedSecurityInfoW(str(path), _SE_FILE_OBJECT, _OWNER_INFO | _DACL_INFO,
696
- *[ctypes.byref(r) for r in refs], ctypes.byref(sd))
697
- if code:
698
- raise _win_error("GetNamedSecurityInfoW", code)
699
- try:
700
- text = wintypes.LPWSTR()
701
- if not _advapi32.ConvertSecurityDescriptorToStringSecurityDescriptorW(
702
- sd, 1, _OWNER_INFO | _DACL_INFO, ctypes.byref(text), None):
703
- raise _win_error("ConvertSecurityDescriptorToStringSecurityDescriptorW")
704
- try:
705
- return text.value or ""
706
- finally:
707
- _kernel32.LocalFree(text)
708
- finally:
709
- _kernel32.LocalFree(sd)
710
-
711
- def windows_acl(path: Path) -> Tuple[str, List[Dict[str, Any]]]:
712
- """(owner SID, the ACEs on the object itself as ``{type, sid, mask}``), read from its SDDL (acl_from_sddl)."""
713
- return acl_from_sddl(_sddl(path), _canonical_sid)
714
-
715
- def _win_protect(path: Path, folder: bool) -> None:
716
- """A protected list (inheritance off): this user, SYSTEM, Administrators, full control;
717
- inherited by what a folder holds."""
718
- inherit = "OICI" if folder else ""
719
- sddl = "D:P" + "".join("(A;%s;FA;;;%s)" % (inherit, who) for who in (windows_user_sid(), "SY", "BA"))
720
- sd = _PVOID()
721
- if not _advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, 1, ctypes.byref(sd), None):
722
- raise _win_error("ConvertStringSecurityDescriptorToSecurityDescriptorW")
723
- try:
724
- present, defaulted, dacl = wintypes.BOOL(), wintypes.BOOL(), _PVOID()
725
- if not _advapi32.GetSecurityDescriptorDacl(sd, ctypes.byref(present), ctypes.byref(dacl), ctypes.byref(defaulted)):
726
- raise _win_error("GetSecurityDescriptorDacl")
727
- code = _advapi32.SetNamedSecurityInfoW(str(path), _SE_FILE_OBJECT, _DACL_INFO | _PROTECTED_DACL,
728
- None, None, dacl, None)
729
- if code:
730
- raise _win_error("SetNamedSecurityInfoW", code)
731
- finally:
732
- _kernel32.LocalFree(sd)
733
-
734
- _kernel32.CreateNamedPipeW.argtypes = [wintypes.LPCWSTR] + [wintypes.DWORD] * 6 + [_PVOID]
735
- _kernel32.CreateNamedPipeW.restype = wintypes.HANDLE
736
- _INVALID_HANDLE = wintypes.HANDLE(-1).value
737
-
738
- def _create_first_pipe(name: str) -> Any:
739
- """The pipe's first instance, or None when another process holds it (ERROR_ACCESS_DENIED)."""
740
- # PIPE_ACCESS_INBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE; PIPE_REJECT_REMOTE_CLIENTS; unlimited instances,
741
- # as libuv creates them: then a second first-instance attempt, ours or the Node kit's, is refused with
742
- # ERROR_ACCESS_DENIED (Node: EADDRINUSE), never ERROR_PIPE_BUSY.
743
- handle = _kernel32.CreateNamedPipeW(name, 0x1 | 0x00080000, 0x8, 255, 0, 0, 0, None)
744
- if handle == _INVALID_HANDLE:
745
- code = ctypes.get_last_error()
746
- if code in (5, 231): # ERROR_ACCESS_DENIED, ERROR_PIPE_BUSY: another process holds it
747
- return None
748
- raise _win_error("CreateNamedPipeW", code)
749
- return handle
750
-
751
- def _close_handle(handle: Any) -> None:
752
- _kernel32.CloseHandle(handle)
753
-
754
- _kernel32.WaitNamedPipeW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD]
755
- _kernel32.WaitNamedPipeW.restype = wintypes.BOOL
756
-
757
- def _pipe_exists(name: str) -> bool:
758
- """Some process holds the pipe: waiting for it does not fail with ERROR_FILE_NOT_FOUND. Nothing connects."""
759
- if _kernel32.WaitNamedPipeW(name, 1):
760
- return True
761
- return ctypes.get_last_error() != 2
762
-
763
- def _win_token_problem(path: Path, info: os.stat_result) -> Optional[str]:
764
- profile = os.path.normcase(os.path.realpath(os.environ.get("USERPROFILE") or str(Path.home())))
765
- real = os.path.normcase(os.path.realpath(str(path)))
766
- try:
767
- inside = os.path.commonpath([profile, real]) == profile
768
- except ValueError: # another drive
769
- inside = False
770
- owner, aces = windows_acl(path)
771
- file = {"isRegularFile": stat.S_ISREG(info.st_mode), "isReparsePoint": bool(getattr(info, "st_file_attributes", 0) & _REPARSE_POINT),
772
- "realPathInProfile": inside, "ownerSid": owner, "aces": aces}
773
- return token_file_problem(path, file, windows_user_sid())
774
- else:
775
- def _create_first_pipe(name: str) -> Any: # noqa: ARG001
776
- raise ServiceError("named pipes exist only on Windows")
777
-
778
- def _close_handle(handle: Any) -> None: # noqa: ARG001
779
- raise ServiceError("handles exist only on Windows")
780
-
781
- def _pipe_exists(name: str) -> bool: # noqa: ARG001
782
- raise ServiceError("named pipes exist only on Windows")
783
-
784
- def _win_protect(path: Path, folder: bool) -> None: # noqa: ARG001 - POSIX uses modes
785
- raise ServiceError("Windows ACLs exist only on Windows")
786
-
787
- def _win_token_problem(path: Path, info: os.stat_result) -> Optional[str]: # noqa: ARG001
788
- raise ServiceError("Windows token rules apply only on Windows")
789
-
790
-
791
- # --- token -------------------------------------------------------------------
792
-
793
- def ensure_token(path: Path) -> str:
794
- """Create the service token once (0600, owner only); never rotate silently."""
795
- path = Path(path)
796
- if path.exists():
797
- return read_token(path)
798
- ensure_private_dir(path.parent)
799
- token = secrets.token_urlsafe(32)
800
- atomic_write(path, token.encode(), 0o600)
801
- return token
802
-
803
-
804
- def read_token(path: Path) -> str:
805
- """Refuse a symlink, a file owned by someone else, or one others can read.
806
-
807
- On Windows the rule is service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057):
808
- windows_token_acl_problem over the file's flags, owner and ACEs; a refusal names the SID,
809
- never the contents."""
810
- path = Path(path)
811
- info = os.lstat(path)
812
- if stat.S_ISLNK(info.st_mode):
813
- raise ServiceError("Token file %s is a symlink; refusing it." % path)
814
- if WINDOWS:
815
- problem = _win_token_problem(path, info)
816
- if problem:
817
- raise ServiceError(problem)
818
- else:
819
- if info.st_uid != os.getuid():
820
- raise ServiceError("Token file %s belongs to another user." % path)
821
- if info.st_mode & 0o077:
822
- raise ServiceError("Token file %s is readable by others; set mode 0600." % path)
823
- token = path.read_text().strip()
824
- if len(token) < 16:
825
- raise ServiceError("Token file %s holds no usable token." % path)
826
- return token
827
-
828
-
829
- def token_matches(presented: Optional[str], token: str, scheme: str = "Bearer") -> bool:
830
- if not presented:
831
- return False
832
- value = presented
833
- if scheme == "Bearer":
834
- if not presented.startswith("Bearer "):
835
- return False
836
- value = presented[len("Bearer "):]
837
- return hmac.compare_digest(value.strip().encode(), token.encode())
838
-
839
-
840
- # --- network guard -----------------------------------------------------------
841
-
842
- def check_request(port: int, host: Optional[str], origin: Optional[str] = None,
843
- sec_fetch_site: Optional[str] = None) -> Optional[str]:
844
- """Return why a request must be refused (403), or None when it may proceed."""
845
- allowed_hosts = {"127.0.0.1:%d" % port, "localhost:%d" % port, "[::1]:%d" % port}
846
- if host not in allowed_hosts:
847
- return "Host %r is not this service." % host
848
- if origin is not None and origin not in {"http://" + h for h in allowed_hosts}:
849
- return "Origin %r is not this service." % origin
850
- if sec_fetch_site == "cross-site":
851
- return "Cross-site requests are refused."
852
- return None
853
-
854
-
855
- # --- descriptor --------------------------------------------------------------
856
-
857
- class LoopbackHTTPServer(http.server.ThreadingHTTPServer):
858
- """ThreadingHTTPServer for a loopback service: threads are daemons, and the bind asks
859
- no resolver. HTTPServer.server_bind() calls socket.getfqdn() between bind() and listen();
860
- on a Mac with a slow resolver the port then stays bound but silent, so a host sees
861
- neither an answer nor a refusal."""
862
-
863
- daemon_threads = True
864
-
865
- def server_bind(self) -> None:
866
- socketserver.TCPServer.server_bind(self)
867
- host, port = self.server_address[:2]
868
- self.server_name, self.server_port = host, port
869
-
870
-
871
- def descriptor_path(service_id: str, instance: str = "default", directory: Optional[Path] = None) -> Path:
872
- return (directory or services_dir()) / ("%s.%s.json" % (service_id, instance))
873
-
874
-
875
- def read_descriptors(directory: Optional[Path] = None) -> List[Tuple[Path, Dict[str, Any]]]:
876
- root = directory or services_dir()
877
- found: List[Tuple[Path, Dict[str, Any]]] = []
878
- if not root.is_dir():
879
- return found
880
- for path in sorted(root.glob("*.json")):
881
- try:
882
- found.append((path, json.loads(path.read_text(encoding="utf-8"))))
883
- except (OSError, ValueError):
884
- continue
885
- return found
886
-
887
-
888
- def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
889
- """Structural checks mirroring service-descriptor.schema.json (the schema stays normative)."""
890
- problems: List[str] = []
891
- remote = placement_of(descriptor) == "remote"
892
- if "placement" in descriptor and descriptor["placement"] not in PLACEMENTS:
893
- problems.append("placement must be local or remote")
894
- life_in = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
895
- app = life_in.get("manager") == "person"
896
- # DEC-0069: an app's descriptor says it is installed, never that it runs; its live origin is in its own liveness file.
897
- required = ("protocol", "id", "instance", "name", "auth", "lifecycle", "installedAt", "installedBy") + (() if app else ("origin",))
898
- if not remote:
899
- required = required + ("paths",)
900
- for key in required:
901
- if key not in descriptor:
902
- problems.append("missing %s" % key)
903
- if problems:
904
- return problems
905
- if descriptor["protocol"] != PROTOCOL:
906
- problems.append("protocol must be %s" % PROTOCOL)
907
- if not _ID.match(str(descriptor["id"])):
908
- problems.append("id must match %s" % _ID.pattern)
909
- if not _INSTANCE.match(str(descriptor["instance"])):
910
- problems.append("instance must match %s" % _INSTANCE.pattern)
911
- if remote:
912
- problems.extend(remote_origin_problems(descriptor["origin"]))
913
- elif "origin" in descriptor and not _ORIGIN.match(str(descriptor["origin"])):
914
- problems.append("origin must be http://127.0.0.1:<port>")
915
- auth = descriptor.get("auth") or {}
916
- if "tokenFile" not in auth:
917
- problems.append("auth.tokenFile is required")
918
- elif not _LOCAL_PATH.match(str(auth["tokenFile"])):
919
- problems.append("auth.tokenFile must be a local path, never a network share")
920
- paths = descriptor.get("paths") or {}
921
- for value in ([paths["data"]] if "data" in paths else []) + list(paths.get("logs") or []):
922
- if not _LOCAL_PATH.match(str(value)):
923
- problems.append("paths must be local paths, never a network share: %r" % value)
924
- header = auth.get("header", "Authorization")
925
- scheme = auth.get("scheme", "Bearer")
926
- if not (isinstance(header, str) and _AUTH_HEADER.fullmatch(header)):
927
- problems.append("auth.header must match %s" % _AUTH_HEADER.pattern)
928
- if scheme not in ("Bearer", "none"):
929
- problems.append("auth.scheme is Bearer or none")
930
- if header != "Authorization" and scheme != "none":
931
- problems.append("a custom auth header carries the raw token: scheme must be none")
932
- life = descriptor.get("lifecycle") or {}
933
- manager = life.get("manager")
934
- if manager not in MANAGERS:
935
- problems.append("lifecycle.manager must be launchd, systemd, task-scheduler, node, person or none")
936
- # DEC-0069: `app` is the installed app of a person's descriptor, and nothing else's.
937
- if manager == "person" and "app" not in life:
938
- problems.append("a person's app declares lifecycle.app, the installed app")
939
- if "app" in life and manager != "person":
940
- problems.append("lifecycle.app belongs to manager person only")
941
- if "app" in life and not _LOCAL_PATH.match(str(life["app"])):
942
- problems.append("lifecycle.app must be a local path")
943
- if manager == "launchd" and not (life.get("label") and str(life.get("plist", "")).endswith(".plist")):
944
- problems.append("a launchd service declares label and plist")
945
- if "plist" in life and not _LOCAL_PATH.match(str(life["plist"])):
946
- problems.append("lifecycle.plist must be a local path")
947
- # DEC-0057, DEC-0061, DEC-0069: a descriptor names one supervisor; launchd's label and plist never sit beside another.
948
- for field in ("label", "plist"):
949
- if field in life and manager in _NOT_LAUNCHD:
950
- problems.append("lifecycle.%s belongs to launchd, not to %s" % (field, manager))
951
- # DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
952
- for field, owner, pattern in (("unit", "systemd", _UNIT), ("task", "task-scheduler", _TASK)):
953
- if manager == owner and field not in life:
954
- problems.append("a %s service declares %s" % (owner, field))
955
- if field in life and manager != owner:
956
- problems.append("lifecycle.%s belongs to manager %s only" % (field, owner))
957
- if field in life and not pattern.match(str(life[field])):
958
- problems.append("lifecycle.%s must match %s" % (field, pattern.pattern))
959
- if remote:
960
- if manager != "none":
961
- problems.append("a remote service is supervised by its platform: lifecycle.manager must be none")
962
- for field, of in (("label", "launchd"), ("plist", "launchd"), ("unit", "systemd"), ("task", "Task Scheduler")):
963
- if field in life:
964
- problems.append("a remote service has no %s %s" % (of, field))
965
- if "update" in (descriptor.get("commands") or {}):
966
- problems.append("a remote service declares no update command")
967
- problems.extend(descriptor_member_problems(descriptor))
968
- for name, argv in (descriptor.get("commands") or {}).items():
969
- if name not in ("doctor", "update"):
970
- problems.append("unknown command %s" % name)
971
- elif not isinstance(argv, list) or not argv or not all(isinstance(a, str) for a in argv):
972
- problems.append("command %s must be an argument array" % name)
973
- elif not _LOCAL_PATH.match(argv[0]):
974
- problems.append("command %s must start with an absolute or ~/ executable" % name)
975
- return problems
976
-
977
-
978
- # --- DEC-0061 and DEC-0069: who installed it, how its model calls sign in, offered to others, an app's own descriptor ---
979
-
980
- #: The organization edition's data classes, least sensitive first (DEC-0061, DEC-0053).
981
- PEER_DATA_CLASSES = ("public", "internal", "confidential", "restricted")
982
- #: The kind of a runner's sign-in, never a credential's value (DEC-0061).
983
- CREDENTIAL_KINDS = ("subscription", "key", "platform-metered", "none")
984
- #: A subscription runs only in its vendor's runner (DEC-0046): runner kind → login.
985
- SUBSCRIPTION_RUNNERS = {"claude-code": "claude", "kimi-code": "kimi", "codex": "chatgpt"}
986
- _PEER_ENDPOINT = re.compile(r"^https://(?P<host>([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?$", re.ASCII)
987
- _RESIDENCY = re.compile(r"^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])$", re.ASCII)
988
- _RUNNER_KIND_NAME = re.compile(r"^[a-z][a-z0-9-]{1,62}$", re.ASCII)
989
-
990
-
991
- def _pointer(name: str) -> str:
992
- """A JSON Pointer token (RFC 6901 §4)."""
993
- return str(name).replace("~", "~0").replace("/", "~1")
994
-
995
-
996
- def _text(value: Any, low: int = 1, high: int = 200) -> bool:
997
- return isinstance(value, str) and low <= len(value) <= high
998
-
999
-
1000
- def descriptor_member_problems(descriptor: Dict[str, Any]) -> List[str]:
1001
- """DEC-0061 and DEC-0069 on a descriptor's `installedBy`, `runner` and `peer`, as the schema shapes them, with
1002
- FAC-SEM-085 (a peer on a reserved name) and FAC-SEM-086 (a credential-shaped value in `runner` or `installedBy`,
1003
- named by its place, never its value)."""
1004
- import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
1005
- problems: List[str] = []
1006
- life = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
1007
- by = descriptor.get("installedBy")
1008
- places: List[Tuple[str, Any]] = []
1009
- if isinstance(by, str):
1010
- if not _text(by):
1011
- problems.append("installedBy is the installer and its version, 1 to 200 characters")
1012
- places.append(("/installedBy", by))
1013
- elif isinstance(by, dict):
1014
- problems += ["installedBy has no member %s" % k for k in by if k not in ("by", "role", "tool")]
1015
- if by.get("by") not in ("person", "node", "organization"):
1016
- problems.append("installedBy.by is person, node or organization")
1017
- if by.get("by") == "person" and by.get("role") not in ("operator", "administrator"):
1018
- problems.append("installedBy.role is operator or administrator for a person")
1019
- if by.get("by") != "person" and "role" in by:
1020
- problems.append("installedBy.role belongs to a person only")
1021
- if not _text(by.get("tool")):
1022
- problems.append("installedBy.tool is the installer and its version, 1 to 200 characters")
1023
- places += [("/installedBy/%s" % _pointer(k), v) for k, v in by.items()]
1024
- else:
1025
- problems.append("installedBy is the installer's name or {by, role?, tool}")
1026
- if life.get("manager") == "person" and not (isinstance(by, dict) and by.get("by") == "person"):
1027
- problems.append("an app's descriptor names who installed it as a person: installedBy {by: person, role, tool} (DEC-0069)")
1028
- runner = descriptor.get("runner")
1029
- if "runner" in descriptor:
1030
- if not isinstance(runner, dict):
1031
- problems.append("runner is {kind?, credentialKind}")
1032
- runner = {}
1033
- problems += ["runner has no member %s" % k for k in runner if k not in ("kind", "credentialKind")]
1034
- if runner.get("credentialKind") not in CREDENTIAL_KINDS:
1035
- problems.append("runner.credentialKind is subscription, key, platform-metered or none")
1036
- if "kind" in runner and not (isinstance(runner["kind"], str) and _RUNNER_KIND_NAME.match(runner["kind"])):
1037
- problems.append("runner.kind is a runner kind's name")
1038
- if runner.get("credentialKind") == "subscription" and runner.get("kind") not in SUBSCRIPTION_RUNNERS:
1039
- problems.append("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex")
1040
- places += [("/runner/%s" % _pointer(k), v) for k, v in runner.items()]
1041
- if "peer" in descriptor:
1042
- peer = descriptor.get("peer")
1043
- if not isinstance(peer, dict):
1044
- problems.append("peer is {endpoint, custody, residency, maxDataClass}")
1045
- peer = {}
1046
- problems += ["peer has no member %s" % k for k in peer if k not in ("endpoint", "custody", "residency", "maxDataClass")]
1047
- endpoint = peer.get("endpoint")
1048
- match = _PEER_ENDPOINT.match(endpoint) if isinstance(endpoint, str) and len(endpoint) <= 2048 else None
1049
- if not match:
1050
- problems.append("peer.endpoint is https://<dns-name>[:<port>][/<path>], no query, fragment, userinfo or IP literal")
1051
- elif _RESERVED_HOST.search(match.group("host")):
1052
- problems.append("FAC-SEM-085: a service offered to others cannot be reached at the reserved name %s" % match.group("host"))
1053
- if peer.get("custody") not in ("organization-managed", "personal"):
1054
- problems.append("peer.custody is organization-managed or personal")
1055
- residency = peer.get("residency")
1056
- if (not isinstance(residency, list) or not 1 <= len(residency) <= 32 or len(set(map(json.dumps, residency))) != len(residency)
1057
- or not all(isinstance(r, str) and _RESIDENCY.match(r) for r in residency)):
1058
- problems.append("peer.residency is 1 to 32 distinct ISO 3166-1 alpha-2 codes in upper case, EU admitted, no user-assigned code")
1059
- if peer.get("maxDataClass") not in PEER_DATA_CLASSES:
1060
- problems.append("peer.maxDataClass is public, internal, confidential or restricted")
1061
- if "runner" not in descriptor:
1062
- problems.append("a service offered to others declares runner (DEC-0061)")
1063
- elif isinstance(runner, dict) and runner.get("credentialKind") == "subscription":
1064
- problems.append("a service offered to others never runs on a subscription (DEC-0061)")
1065
- for place, value in places:
1066
- if isinstance(value, str) and fabric_secrets.secret_argument(value):
1067
- problems.append("FAC-SEM-086: %s holds a value shaped like a credential; it names a kind, a role or an installer" % place)
1068
- return problems
1069
-
1070
-
1071
- def peer_refusal(descriptor: Any, data_class: Any) -> Optional[str]:
1072
- """The host's check before it routes a request from anyone but the service's operator (DEC-0061, FAC-SEM-088):
1073
- `not-offered` (no `peer`), `peer-invalid` (its maximum is not one of the four classes), `data-class-unknown` (the
1074
- request's class is not one of them, exactly as written) or `above-max-data-class`; None when it may. The contract's
1075
- `peerRefusal`."""
1076
- if not isinstance(descriptor, dict) or not isinstance(descriptor.get("peer"), dict):
1077
- return "not-offered"
1078
- ceiling = descriptor["peer"].get("maxDataClass")
1079
- if not isinstance(ceiling, str) or ceiling not in PEER_DATA_CLASSES:
1080
- return "peer-invalid"
1081
- if not isinstance(data_class, str) or data_class not in PEER_DATA_CLASSES:
1082
- return "data-class-unknown"
1083
- return "above-max-data-class" if PEER_DATA_CLASSES.index(data_class) > PEER_DATA_CLASSES.index(ceiling) else None
1084
-
1085
-
1086
- def app_descriptor_action(existing: Any, tool: str, app_path: str) -> str:
1087
- """What an app that writes its own descriptor does with it on a start (DEC-0069): `keep` when it runs from a
1088
- translocated copy (macOS App Translocation); otherwise `write` when there is none, when it is not an app's descriptor,
1089
- when its version (`installedBy.tool`) or its path (`lifecycle.app`) differs; `keep` otherwise. The contract's
1090
- `appDescriptorAction`."""
1091
- if "/AppTranslocation/" in app_path:
1092
- return "keep"
1093
- if not isinstance(existing, dict) or not isinstance(existing.get("installedBy"), dict):
1094
- return "write"
1095
- recorded = existing["lifecycle"].get("app", _MISSING_APP) if isinstance(existing.get("lifecycle"), dict) else _MISSING_APP
1096
- return "keep" if existing["installedBy"].get("tool", _MISSING_APP) == tool and recorded == app_path else "write"
1097
-
1098
-
1099
- _MISSING_APP = object()
1100
-
1101
-
1102
- def app_descriptor_stale(descriptor: Any, app_present: bool) -> bool:
1103
- """Whether a reader treats a descriptor as stale (DEC-0069): an app's descriptor whose installed app it did not find.
1104
- A supervised service's descriptor is never stale by this test. The contract's `appDescriptorStale`."""
1105
- life = descriptor.get("lifecycle") if isinstance(descriptor, dict) else None
1106
- return isinstance(life, dict) and life.get("manager") == "person" and not app_present
1107
-
1108
-
1109
- def app_present(descriptor: Dict[str, Any]) -> bool:
1110
- """Whether `lifecycle.app` of an app's descriptor exists on this machine (a bundle is a folder, an executable a file)."""
1111
- life = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
1112
- app = life.get("app")
1113
- try:
1114
- return isinstance(app, str) and expand(app).exists()
1115
- except ServiceError: # not a local path: nothing installed there
1116
- return False
1117
-
1118
-
1119
- #: Fabric's own descriptor (Fabric ADR-0139): `services/fabric.default.json`.
1120
- FABRIC_SERVICE = ("fabric", "default")
1121
-
1122
-
1123
- def fabric_button_presence(directory: Optional[Path] = None) -> str:
1124
- """`present` or `absent` for Fabric's button (DEC-0040 FB-5, DEC-0069, Fabric SCN-142): present when Fabric's own
1125
- descriptor is registered and, for an app's descriptor, its app is still installed. A server sets
1126
- `data-fabric="absent"` on the button's script otherwise. The button only follows a link, so the liveness file is
1127
- not read here; a caller that sends anything reads it."""
1128
- path = descriptor_path(*FABRIC_SERVICE, directory=directory)
1129
- try:
1130
- found = json.loads(path.read_text(encoding="utf-8"))
1131
- except (OSError, ValueError):
1132
- return "absent"
1133
- if not isinstance(found, dict) or validate_descriptor(found):
1134
- return "absent"
1135
- return "absent" if app_descriptor_stale(found, app_present(found)) else "present"
1136
-
1137
-
1138
- def placement_of(descriptor: Dict[str, Any]) -> str:
1139
- """DEC-0019: ``local`` unless the descriptor says ``remote``."""
1140
- return "remote" if (descriptor or {}).get("placement") == "remote" else "local"
1141
-
1142
-
1143
- def remote_origin_problems(origin: Any) -> List[str]:
1144
- """Problems with a remote origin: https, a public DNS name, an optional port, nothing else."""
1145
- match = _REMOTE_ORIGIN.match(str(origin or ""))
1146
- if not match:
1147
- return ["a remote origin must be https://<dns-name>[:<port>] with no path, query or IP literal"]
1148
- if _RESERVED_HOST.search(match.group(1)):
1149
- return ["a remote service cannot live on the reserved name %s" % match.group(1)]
1150
- if match.group(2) is not None and not 1 <= int(match.group(2)) <= 65535:
1151
- return ["the origin port is out of range"]
1152
- return []
1153
-
1154
-
1155
- def port_of(origin: str) -> int:
1156
- match = _ORIGIN.match(origin)
1157
- if not match:
1158
- raise ServiceError("Origin %r is not http://127.0.0.1:<port>." % origin)
1159
- return int(match.group(1))
1160
-
1161
-
1162
- def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = None) -> Path:
1163
- """Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim)."""
1164
- problems = validate_descriptor(descriptor)
1165
- if problems:
1166
- raise ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
1167
- root = directory or services_dir()
1168
- me = "%s.%s" % (descriptor["id"], descriptor["instance"])
1169
- # DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
1170
- port = None if placement_of(descriptor) == "remote" or "origin" not in descriptor else port_of(descriptor["origin"])
1171
- for path, other in read_descriptors(root):
1172
- key = "%s.%s" % (other.get("id"), other.get("instance", "default"))
1173
- if key == me or port is None: # a remote origin's port is another computer's; an app claims none
1174
- continue
1175
- try:
1176
- other_port = port_of(str(other.get("origin", "")))
1177
- except ServiceError:
1178
- continue
1179
- if other_port == port:
1180
- raise ServiceError("Port %d is already claimed by %s (%s)." % (port, key, path))
1181
- ensure_private_dir(root)
1182
- target = descriptor_path(descriptor["id"], descriptor["instance"], root)
1183
- atomic_write(target, (json.dumps(descriptor, indent=2, ensure_ascii=False) + "\n").encode(), 0o600)
1184
- return target
1185
-
1186
-
1187
- def remove_descriptor(service_id: str, instance: str = "default", directory: Optional[Path] = None) -> bool:
1188
- try:
1189
- descriptor_path(service_id, instance, directory).unlink()
1190
- return True
1191
- except FileNotFoundError:
1192
- return False
1193
-
1194
-
1195
- # --- build identity & well-known ------------------------------------------------
1196
-
1197
- def build_info(repo_root: Optional[Path] = None, package_file: Optional[Path] = None) -> Dict[str, Any]:
1198
- """Commit (env FABRIC_BUILD_COMMIT, else git) or a sha256 digest of the package file."""
1199
- commit = os.environ.get("FABRIC_BUILD_COMMIT")
1200
- dirty: Optional[bool] = None
1201
- if not commit and repo_root is not None:
1202
- try:
1203
- commit = subprocess.run(["git", "-C", str(repo_root), "rev-parse", "--short=12", "HEAD"],
1204
- capture_output=True, text=True, timeout=5, check=True).stdout.strip()
1205
- status = subprocess.run(["git", "-C", str(repo_root), "status", "--porcelain", "--untracked-files=no"],
1206
- capture_output=True, text=True, timeout=5, check=True).stdout
1207
- dirty = bool(status.strip())
1208
- except (OSError, subprocess.SubprocessError):
1209
- commit = None
1210
- info: Dict[str, Any] = {"builtAt": now_iso()}
1211
- if commit:
1212
- info["commit"] = commit
1213
- if dirty is not None:
1214
- info["dirty"] = dirty
1215
- elif package_file is not None and Path(package_file).is_file():
1216
- info["digest"] = "sha256:" + hashlib.sha256(Path(package_file).read_bytes()).hexdigest()
1217
- else:
1218
- raise ServiceError("No build identity: set FABRIC_BUILD_COMMIT, pass repo_root or package_file.")
1219
- return info
1220
-
1221
-
1222
- _THEME_ID = re.compile(r"^[a-z][a-z0-9-]{0,31}$")
1223
- _KIT_REF = re.compile(r"^[a-z][a-z0-9-]{0,63}@(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$")
1224
-
1225
-
1226
- def dashboard_extension_problems(value: Any) -> List[str]:
1227
- """What makes value not a valid ED-9 declaration (service-dashboard-extension.schema.json)."""
1228
- if not isinstance(value, dict):
1229
- return ["the declaration is not an object"]
1230
- out: List[str] = []
1231
- extra = sorted(set(value) - {"minWidth", "appearance", "themes", "kit"})
1232
- if extra:
1233
- out.append("unknown field(s): %s" % ", ".join(extra))
1234
- width = value.get("minWidth")
1235
- if isinstance(width, bool) or not isinstance(width, int):
1236
- out.append("minWidth is required and must be a whole number of CSS pixels")
1237
- elif not 360 <= width <= 1280:
1238
- out.append("minWidth %d is outside 360…1280" % width)
1239
- if "appearance" in value:
1240
- a = value["appearance"]
1241
- if not isinstance(a, list) or len(a) > 2 or len(set(map(str, a))) != len(a) or any(x not in ("scheme", "theme") for x in a):
1242
- out.append("appearance is a list of distinct fields from scheme, theme")
1243
- if "themes" in value:
1244
- t = value["themes"]
1245
- if not isinstance(t, list) or not 1 <= len(t) <= 32 or len(set(map(str, t))) != len(t) \
1246
- or any(not isinstance(x, str) or not _THEME_ID.match(x) for x in t):
1247
- out.append("themes is 1…32 distinct theme ids matching ^[a-z][a-z0-9-]{0,31}$")
1248
- if "kit" in value and not (isinstance(value["kit"], str) and _KIT_REF.match(value["kit"])):
1249
- out.append("kit is name@semver, for example %s" % DASHBOARD_KIT)
1250
- return out
1251
-
1252
-
1253
- def dashboard_extension(min_width: int = 360, appearance: Sequence[str] = ("scheme", "theme"),
1254
- themes: Optional[Sequence[str]] = REFERENCE_THEMES,
1255
- kit: Optional[str] = DASHBOARD_KIT) -> Dict[str, Any]:
1256
- """The `extensions` entry a dashboard built from the kit declares (DEC-0036 ED-9). Declare it only
1257
- when the page meets ED-1…ED-6 — check_dashboard_layout.mjs measures that."""
1258
- value: Dict[str, Any] = {"minWidth": min_width}
1259
- if appearance:
1260
- value["appearance"] = list(appearance)
1261
- if themes:
1262
- value["themes"] = list(themes)
1263
- if kit:
1264
- value["kit"] = kit
1265
- problems = dashboard_extension_problems(value)
1266
- if problems:
1267
- raise ServiceError("Dashboard declaration: %s." % "; ".join(problems))
1268
- return {DASHBOARD_EXTENSION_KEY: value}
1269
-
1270
-
1271
- _PROFILE_ID = re.compile(r"^[a-z][a-z0-9-]{0,62}$")
1272
- _RUNNER_KIND = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
1273
- _MODEL_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._:-]{0,127}$")
1274
- ROUTES = ("subscription", "key")
1275
- #: A usage row's route (DEC-0046, DEC-0061): beside a configuration's two, the hosting platform's own metered models.
1276
- USAGE_ROUTES = ROUTES + ("platform-metered",)
1277
-
1278
-
1279
- def configuration_problems(doc: Any) -> List[str]:
1280
- """DEC-0046: the well-known `configuration` — {slots, resolved} as service-well-known.schema.json types it."""
1281
- if not isinstance(doc, dict):
1282
- return ["configuration is an object"]
1283
- problems = ["configuration has no member %s" % k for k in doc if k not in ("slots", "resolved")]
1284
- slots = doc.get("slots")
1285
- if not isinstance(slots, list) or len(slots) > 2 or len(set(map(str, slots))) != len(slots) or not all(s in ("runner", "model") for s in slots):
1286
- problems.append("slots is a list of runner and model, each once")
1287
- resolved = doc.get("resolved")
1288
- if not isinstance(resolved, dict):
1289
- return problems + ["resolved is an object"]
1290
- for k, v in resolved.items():
1291
- ok = {"from": lambda x: x in ("request", "agent", "binding", "estate"),
1292
- "runnerProfile": lambda x: isinstance(x, str) and bool(_PROFILE_ID.match(x)),
1293
- "modelProfile": lambda x: isinstance(x, str) and bool(_PROFILE_ID.match(x)),
1294
- "runnerKind": lambda x: isinstance(x, str) and bool(_RUNNER_KIND.match(x)),
1295
- "route": lambda x: x in ROUTES,
1296
- "models": lambda x: isinstance(x, list) and len(x) <= 16 and all(isinstance(m, str) and _MODEL_ID.match(m) for m in x)}.get(k)
1297
- if ok is None:
1298
- problems.append("resolved has no member %s" % k)
1299
- elif not ok(v):
1300
- problems.append("resolved.%s is not valid" % k)
1301
- return problems
1302
-
1303
-
1304
- def build_well_known(*, service_id: str, instance: str, name: str, version: str, build: Dict[str, Any],
1305
- started_at: str, status: str, degraded: Sequence[Dict[str, str]],
1306
- surfaces: Dict[str, Any], summary: Optional[Sequence[Dict[str, Any]]] = None,
1307
- update_available: Optional[str] = None, pid: Optional[int] = None,
1308
- extensions: Optional[Dict[str, Any]] = None,
1309
- configuration: Optional[Dict[str, Any]] = None,
1310
- protocols: Optional[Sequence[str]] = None) -> Dict[str, Any]:
1311
- """The unauthenticated identity answer. `degraded` is always present; ready means empty.
1312
- `configuration` (DEC-0046) says what the operator can configure and what applies now; fabric_profiles builds it.
1313
- `extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration
1314
- in it must be valid (dashboard_extension builds one)."""
1315
- if status not in STATUSES:
1316
- raise ServiceError("status must be one of %s." % ", ".join(STATUSES))
1317
- degraded = list(degraded)
1318
- if status == "ready" and degraded:
1319
- status = "degraded"
1320
- if "events" not in surfaces:
1321
- raise ServiceError("surfaces.events is required.")
1322
- doc: Dict[str, Any] = {
1323
- "protocol": PROTOCOL,
1324
- "service": {"id": service_id, "instance": instance, "name": name, "version": version, "build": build},
1325
- "process": {"pid": pid or os.getpid(), "startedAt": started_at},
1326
- "status": status,
1327
- "degraded": degraded,
1328
- "surfaces": surfaces,
1329
- "update": {"available": update_available},
1330
- }
1331
- if summary:
1332
- doc["summary"] = list(summary)[:6]
1333
- if extensions:
1334
- if DASHBOARD_EXTENSION_KEY in extensions:
1335
- problems = dashboard_extension_problems(extensions[DASHBOARD_EXTENSION_KEY])
1336
- if problems:
1337
- raise ServiceError("Dashboard declaration: %s." % "; ".join(problems))
1338
- doc["extensions"] = dict(extensions)
1339
- if configuration is not None:
1340
- problems = configuration_problems(configuration)
1341
- if problems:
1342
- raise ServiceError("configuration: %s." % "; ".join(problems))
1343
- doc["configuration"] = configuration
1344
- if protocols is not None:
1345
- # DEC-0067: the revisions this service accepts. The document is closed for a reader pinned before DEC-0067, so
1346
- # a service lists them only once its hosts are pinned at or after it.
1347
- problems = protocols_findings(list(protocols))
1348
- if problems:
1349
- raise ServiceError("protocols: %s." % "; ".join(f["message"] for f in problems))
1350
- doc["protocols"] = list(protocols)
1351
- return doc
1352
-
1353
-
1354
- # --- DEC-0067: protocol revisions; DEC-0070: an event's machine-readable data --------------------------------------
1355
-
1356
- #: Each contract protocol's published revisions, oldest first (the contract's `PROTOCOL_REVISIONS`, DEC-0067); a test
1357
- #: holds it equal to the contract's register at the pin.
1358
- PROTOCOL_REVISIONS: Dict[str, Tuple[str, ...]] = {
1359
- "access-log": ("1",), "approval": ("1",), "activity-summary": ("1",), "coding-agents": ("1",), "enroll-token": ("1",), "entitlement": ("1",),
1360
- "entitlement-key-set": ("1",), "estate-record": ("1",), "execution-packet": ("1",),
1361
- "fabric-activity": ("0.1",), "fabric-dashboard": ("0.1",), "fabric-delegation": ("0.1",), "fabric-device": ("0.1",),
1362
- "fabric-interop": ("0.1", "0.2"), "fabric-local-channel": ("0.1",), "fabric-local-runner": ("0.1",),
1363
- "fabric-operator-channel": ("0.1",), "fabric-package": ("1",), "fabric-project-comms": ("0.1",), "fabric-proposal": ("1",),
1364
- "fabric-provider": ("0.1",), "fabric-relay": ("0.1",), "fabric-secret-envelope": ("1",), "fabric-service": ("0.1",),
1365
- "fabric-settings-backup": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "memory": ("0.1",), "pairing-message": ("1",), "pipeline": ("0.1",),
1366
- "revocation-delta": ("1",), "session-handoff": ("1",), "share-registration": ("1",),
1367
- "share-registration-receipt": ("1",), "support-quality-daily": ("1",), "window-lease": ("1",),
1368
- }
1369
- MAX_PROTOCOLS = 64
1370
- _REVISION = re.compile(r"([a-z][a-z0-9.-]{0,63})/([0-9]{1,4}(?:\.[0-9]{1,4}){0,2})", re.ASCII)
1371
-
1372
-
1373
- def parse_revision(value: Any) -> Optional[Tuple[str, str]]:
1374
- """`<name>/<revision>` split, or None."""
1375
- match = _REVISION.fullmatch(value) if isinstance(value, str) else None
1376
- return (match.group(1), match.group(2)) if match else None
1377
-
1378
-
1379
- def accepted_revisions(name: str, newest: Optional[str] = None) -> List[str]:
1380
- """The revisions a receiver implementing ``name`` up to ``newest`` accepts: N and, when the register has one, N−1."""
1381
- known = PROTOCOL_REVISIONS.get(name, ())
1382
- if not known:
1383
- return []
1384
- top = len(known) - 1 if newest is None else (known.index(newest) if newest in known else -1)
1385
- if top < 0:
1386
- return []
1387
- return ["%s/%s" % (name, r) for r in reversed(known[max(0, top - 1):top + 1])]
1388
-
1389
-
1390
- def negotiate_revision(received: Any, accepted: Sequence[Any]) -> Dict[str, Any]:
1391
- """A receiver's answer to a document naming ``received`` (DEC-0067, the contract's `negotiateRevision`):
1392
- ``{"ok": True}``; the `schema-unsupported` refusal naming what was received (at most 80 characters) and the accepted
1393
- revisions of that protocol (all of them when it names none the receiver reads); or ``{"fault":
1394
- "no-accepted-revisions"}``, the receiver's own, when it accepts no well-formed revision. Revisions compare exactly."""
1395
- usable: List[str] = []
1396
- for entry in accepted if isinstance(accepted, (list, tuple)) else []:
1397
- if parse_revision(entry) and entry not in usable:
1398
- usable.append(entry)
1399
- usable = usable[:MAX_PROTOCOLS]
1400
- if not usable:
1401
- return {"fault": "no-accepted-revisions"}
1402
- if isinstance(received, str) and received in usable:
1403
- return {"ok": True}
1404
- parsed = parse_revision(received)
1405
- same = [e for e in usable if parsed and parse_revision(e)[0] == parsed[0]]
1406
- return {"error": {"code": "schema-unsupported", "message": "this receiver does not read that protocol revision",
1407
- "data": {"protocol": received[:80] if isinstance(received, str) else "", "accepted": same or usable}}}
1408
-
1409
-
1410
- def protocols_findings(protocols: Any, prefix: str = "/protocols") -> List[Dict[str, str]]:
1411
- """FAC-SEM-099 (DEC-0067): 1 to 64 entries, each `<name>/<revision>` once; at most two revisions of one protocol (an
1412
- unpublished one refused and not counted), and two of a contract protocol are N and N−1, consecutive in the register."""
1413
- flag = lambda at, message: {"code": "FAC-SEM-099", "instancePath": at, "message": message} # noqa: E731
1414
- if not isinstance(protocols, list):
1415
- return [flag(prefix, "protocols is a list of protocol revisions")]
1416
- if not 1 <= len(protocols) <= MAX_PROTOCOLS:
1417
- return [flag(prefix, "protocols lists 1 to %d revisions" % MAX_PROTOCOLS)]
1418
- findings: List[Dict[str, str]] = []
1419
- by_name: Dict[str, List[int]] = {}
1420
- seen: List[str] = []
1421
- for i, entry in enumerate(protocols):
1422
- at = "%s/%d" % (prefix, i)
1423
- parsed = parse_revision(entry)
1424
- if not parsed:
1425
- findings.append(flag(at, "a protocol revision is <name>/<revision>, such as fabric-service/0.1"))
1426
- continue
1427
- if entry in seen:
1428
- findings.append(flag(at, "each protocol revision once"))
1429
- continue
1430
- seen.append(entry)
1431
- known = PROTOCOL_REVISIONS.get(parsed[0])
1432
- if known and parsed[1] not in known:
1433
- findings.append(flag(at, "%s has no published revision %s" % parsed))
1434
- continue
1435
- indices = by_name.setdefault(parsed[0], [])
1436
- indices.append(known.index(parsed[1]) if known else -1)
1437
- if len(indices) > 2:
1438
- findings.append(flag(at, "at most 2 revisions of one protocol: N and N−1"))
1439
- elif known and len(indices) == 2 and abs(indices[0] - indices[1]) != 1:
1440
- findings.append(flag(at, "two revisions of one protocol are N and N−1, consecutive in the register"))
1441
- return findings
1442
-
1443
-
1444
- _SCHEMA_URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:[^\s]+", re.ASCII)
1445
-
1446
-
1447
- def _scalar(value: Any) -> bool:
1448
- return value is None or isinstance(value, (bool, int, float)) or (isinstance(value, str) and len(value) <= 500)
1449
-
1450
-
1451
- def feed_data_problems(data: Any) -> List[str]:
1452
- """DEC-0070's `data {schema, value}` as the schema bounds it, then FAC-SEM-101: no string in `value`, at either
1453
- level, under a credential's name or of a credential's shape (named by its place, never its value)."""
1454
- import fabric_secrets # noqa: E402 - beside this file; the contract's detector
1455
- if not isinstance(data, dict) or set(data) != {"schema", "value"}:
1456
- return ["data is {schema, value}"]
1457
- problems: List[str] = []
1458
- if not (isinstance(data["schema"], str) and _SCHEMA_URI.fullmatch(data["schema"])):
1459
- problems.append("data.schema is the absolute URI of a payload schema the service publishes")
1460
- value = data["value"]
1461
- if not isinstance(value, dict) or len(value) > 32:
1462
- return problems + ["data.value is an object of at most 32 members"]
1463
- for key, member in value.items():
1464
- if _scalar(member):
1465
- continue
1466
- if isinstance(member, list) and len(member) <= 32 and all(_scalar(m) for m in member):
1467
- continue
1468
- if isinstance(member, dict) and len(member) <= 32 and all(_scalar(m) for m in member.values()):
1469
- continue
1470
- problems.append("data.value.%s is a scalar (a string of at most 500 characters), a list of at most 32 scalars or an object of at most 32 scalar members" % key)
1471
- if problems:
1472
- return problems
1473
- for finding in feed_data_findings({"events": [{"data": data}]}):
1474
- problems.append("FAC-SEM-101: %s %s" % (finding["instancePath"].replace("/events/0", ""), finding["message"]))
1475
- return problems
1476
-
1477
-
1478
- def _pointer_token(name: str) -> str:
1479
- return str(name).replace("~", "~0").replace("/", "~1")
1480
-
1481
-
1482
- def feed_data_findings(page: Any) -> List[Dict[str, str]]:
1483
- """FAC-SEM-101 over an events page (the contract's `feedDataFindings`): a list's items and an object's members sit
1484
- under the outer name too; a number is a count, never a credential."""
1485
- import fabric_secrets # noqa: E402
1486
- findings: List[Dict[str, str]] = []
1487
-
1488
- def check(named: bool, member: Any, at: str) -> None:
1489
- if not isinstance(member, str):
1490
- return
1491
- if named:
1492
- findings.append({"code": "FAC-SEM-101", "instancePath": at, "message": "an event's data puts no string under a credential's name"})
1493
- elif fabric_secrets.secret_value(member) or fabric_secrets.secret_url(member):
1494
- findings.append({"code": "FAC-SEM-101", "instancePath": at, "message": "an event's data carries no value shaped like a credential"})
1495
-
1496
- events = page.get("events") if isinstance(page, dict) else None
1497
- for i, event in enumerate(events if isinstance(events, list) else []):
1498
- data = event.get("data") if isinstance(event, dict) else None
1499
- value = data.get("value") if isinstance(data, dict) else None
1500
- if not isinstance(value, dict):
1501
- continue
1502
- for key, member in value.items():
1503
- at = "/events/%d/data/value/%s" % (i, _pointer_token(key))
1504
- named = fabric_secrets.credential_name(key)
1505
- check(named, member, at)
1506
- if isinstance(member, list):
1507
- for j, item in enumerate(member):
1508
- check(named, item, "%s/%d" % (at, j))
1509
- elif isinstance(member, dict):
1510
- for inner, leaf in member.items():
1511
- check(named or fabric_secrets.credential_name(inner), leaf, "%s/%s" % (at, _pointer_token(inner)))
1512
- return findings
1513
-
1514
-
1515
- # --- events ------------------------------------------------------------------
1516
-
1517
- def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subject: Optional[Dict[str, str]] = None,
1518
- link: Optional[str] = None, notify: bool = False, trace_id: Optional[str] = None,
1519
- span_id: Optional[str] = None, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
1520
- """One activity event: one sentence a person reads, never a machine id. `data` (DEC-0070) adds machine-readable
1521
- fields beside the sentence, `{schema, value}`, never a secret: it is refused here, so no feed carries one.
1522
-
1523
- An event about traced work carries its trace as a pair, `trace_id` and `span_id`
1524
- (fabric-interop/0.1 C3.4 c); fabric_interop.trace_ids(traceparent) gives both."""
1525
- if level not in LEVELS:
1526
- raise ServiceError("level must be one of %s." % ", ".join(LEVELS))
1527
- if not _KIND.match(kind):
1528
- raise ServiceError("kind %r must be dotted lowercase." % kind)
1529
- text = " ".join(str(text).split())
1530
- if not text:
1531
- raise ServiceError("An event needs a sentence.")
1532
- if link is not None and (not link.startswith("/") or link.startswith("//")):
1533
- raise ServiceError("link must be a path on this service.")
1534
- if (trace_id is None) != (span_id is None):
1535
- raise ServiceError("An event carries traceId and spanId together, or neither.")
1536
- if trace_id is not None and not (_TRACE_ID.match(trace_id) and _SPAN_ID.match(str(span_id))):
1537
- raise ServiceError("traceId is 32 and spanId 16 lowercase hex characters, not all zeros.")
1538
- event: Dict[str, Any] = {"id": str(event_id), "at": at, "kind": kind, "level": level, "text": text[:500]}
1539
- if subject:
1540
- event["subject"] = subject
1541
- if link:
1542
- event["link"] = link
1543
- if notify:
1544
- event["notify"] = True
1545
- if trace_id is not None:
1546
- event["traceId"], event["spanId"] = trace_id, span_id
1547
- if data is not None:
1548
- problems = feed_data_problems(data)
1549
- if problems:
1550
- raise ServiceError("Event data: %s." % "; ".join(problems))
1551
- event["data"] = {"schema": data["schema"], "value": data["value"]}
1552
- return event
1553
-
1554
-
1555
- def parse_limit(raw: Optional[str]) -> int:
1556
- try:
1557
- value = int(raw) if raw not in (None, "") else EVENTS_DEFAULT_LIMIT
1558
- except ValueError:
1559
- raise ServiceError("limit must be an integer.") from None
1560
- return max(1, min(EVENTS_MAX_LIMIT, value))
1561
-
1562
-
1563
- def events_page(fetch: Callable[[Optional[str], int], Iterable[Dict[str, Any]]],
1564
- after: Optional[str], limit: int) -> Dict[str, Any]:
1565
- """A cursor page over the log the service already keeps.
1566
-
1567
- `fetch(after, limit)` returns events with ids greater than `after` in
1568
- ascending order (or the newest `limit`, ascending, when `after` is None).
1569
- """
1570
- events = list(fetch(after, limit))[:limit]
1571
- cursor = events[-1]["id"] if events else (after or None)
1572
- return {"events": events, "cursor": cursor}
1573
-
1574
-
1575
- class JsonlEventLog:
1576
- """A minimal append-only log for services that keep none yet. Ids are integers as strings."""
1577
-
1578
- def __init__(self, path: Path, keep: int = 5000):
1579
- self.path = Path(path)
1580
- self.keep = keep
1581
-
1582
- def _all(self) -> List[Dict[str, Any]]:
1583
- if not self.path.exists():
1584
- return []
1585
- out = []
1586
- for line in self.path.read_text(encoding="utf-8").splitlines():
1587
- try:
1588
- out.append(json.loads(line))
1589
- except ValueError:
1590
- continue
1591
- return out
1592
-
1593
- def append(self, kind: str, level: str, text: str, **extra: Any) -> Dict[str, Any]:
1594
- rows = self._all()
1595
- next_id = int(rows[-1]["id"]) + 1 if rows else 1
1596
- event = make_event(next_id, now_iso(), kind, level, text, **extra)
1597
- rows.append(event)
1598
- rows = rows[-self.keep:]
1599
- atomic_write(self.path, ("\n".join(json.dumps(r, ensure_ascii=False) for r in rows) + "\n").encode(), 0o600)
1600
- return event
1601
-
1602
- def fetch(self, after: Optional[str], limit: int) -> List[Dict[str, Any]]:
1603
- rows = self._all()
1604
- if after is None:
1605
- return rows[-limit:]
1606
- try:
1607
- floor = int(after)
1608
- except ValueError:
1609
- raise ServiceError("cursor %r is not from this service." % after) from None
1610
- return [r for r in rows if int(r["id"]) > floor][:limit]
1611
-
1612
-
1613
- # --- usage report (DEC-0021) -------------------------------------------------------
1614
-
1615
- USAGE_PATH = "/fabric/v1/usage"
1616
- USAGE_DAYS = 31
1617
- COST_BASES = ("provider", "price-list", "unknown")
1618
- _PROVIDER = re.compile(r"^[a-z][a-z0-9._-]{0,63}$")
1619
-
1620
-
1621
- def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_tokens: int,
1622
- cache_read_tokens: int = 0, cache_write_tokens: int = 0,
1623
- cost_usd: Optional[float] = None, cost_basis: Optional[str] = None,
1624
- at: Optional[str] = None, route: Optional[str] = None,
1625
- project_id: Optional[str] = None, task_run_id: Optional[str] = None,
1626
- tokens_measured: bool = True) -> Dict[str, Any]:
1627
- """One model call, from the provider's own usage numbers. No prompt, output or caller.
1628
-
1629
- `tokens_measured=False` marks a call whose token counts are a lower bound or unknown (the provider returned none,
1630
- the stream was cut): the report counts it in `unmeasuredCalls` and its token totals are a lower bound (DEC-0091).
1631
-
1632
- `project_id` (a URI, kept in its normal form, DEC-0076) and `task_run_id` (the service's own run id) attribute the
1633
- call to the project the service ran it for (DEC-0065); they never name a caller, session or person.
1634
-
1635
- `cost_usd` is None when the call cannot be priced: the report counts it as unpriced and
1636
- never as $0. `cost_basis` is `provider` (the provider reported the charge) or `price-list`
1637
- (computed from a published price list); it is `unknown` when there is no cost."""
1638
- if not _PROVIDER.match(provider or ""):
1639
- raise ServiceError("provider must be a lowercase name such as anthropic or openrouter.")
1640
- model = str(model or "").strip()
1641
- if not model or len(model) > 128:
1642
- raise ServiceError("model must be 1 to 128 characters.")
1643
- counts = {"inputTokens": input_tokens, "outputTokens": output_tokens,
1644
- "cacheReadTokens": cache_read_tokens, "cacheWriteTokens": cache_write_tokens}
1645
- for name, value in counts.items():
1646
- if not isinstance(value, int) or isinstance(value, bool) or value < 0:
1647
- raise ServiceError("%s must be a non-negative integer." % name)
1648
- if cost_usd is not None and (isinstance(cost_usd, bool) or not isinstance(cost_usd, (int, float)) or cost_usd < 0 or cost_usd != cost_usd):
1649
- raise ServiceError("cost_usd must be a non-negative number or None.")
1650
- basis = cost_basis or ("unknown" if cost_usd is None else "provider")
1651
- if basis not in COST_BASES or (cost_usd is None) != (basis == "unknown"):
1652
- raise ServiceError("cost_basis is provider or price-list with a cost, unknown without one.")
1653
- if route is not None and route not in USAGE_ROUTES:
1654
- raise ServiceError("route is subscription, key or platform-metered (DEC-0046, DEC-0061).")
1655
- receipt = {"at": at or now_iso(), "provider": provider, "model": model, **counts,
1656
- "costUsd": None if cost_usd is None else float(cost_usd), "costBasis": basis}
1657
- if tokens_measured is not True:
1658
- if tokens_measured is not False:
1659
- raise ServiceError("tokens_measured is True or False.")
1660
- receipt["tokensMeasured"] = False
1661
- if route is not None:
1662
- receipt["route"] = route # the operator's own login in its vendor's runner, an issued key, or the platform's metered models
1663
- if task_run_id is not None and project_id is None:
1664
- raise ServiceError("a task run belongs to a project: give project_id with task_run_id.")
1665
- if project_id is not None:
1666
- import fabric_identifiers # noqa: E402 - beside this file
1667
- if not (isinstance(project_id, str) and _URI.fullmatch(project_id)):
1668
- raise ServiceError("project_id is the URI of the project the service ran the call for.")
1669
- receipt["projectId"] = fabric_identifiers.normal_identifier(project_id)
1670
- if task_run_id is not None:
1671
- if not (isinstance(task_run_id, str) and _TASK_RUN_ID.fullmatch(task_run_id)):
1672
- raise ServiceError("task_run_id is the service's own run id: ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$.")
1673
- receipt["taskRunId"] = task_run_id
1674
- return receipt
1675
-
1676
-
1677
- _URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:\S+", re.ASCII)
1678
- _TASK_RUN_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", re.ASCII)
1679
-
1680
-
1681
- def _by_project(receipts: List[Dict[str, Any]], first: str, last: str) -> List[Dict[str, Any]]:
1682
- """DEC-0065's `byProject`: the window's calls by the project the service ran them for, each project once (by its
1683
- normal form, DEC-0076) and each task run once within it. A call for no project is in no row."""
1684
- projects: Dict[str, Dict[str, Any]] = {}
1685
- for r in receipts:
1686
- if not (first <= str(r.get("at", ""))[:10] <= last) or not r.get("projectId"):
1687
- continue
1688
- row = projects.setdefault(r["projectId"], {"projectId": r["projectId"], "calls": 0, "unpricedCalls": 0, "_cost": 0.0, "_runs": {}})
1689
- row["calls"] += 1
1690
- if r.get("costUsd") is None:
1691
- row["unpricedCalls"] += 1
1692
- else:
1693
- row["_cost"] += float(r["costUsd"])
1694
- if r.get("taskRunId"):
1695
- run = row["_runs"].setdefault(r["taskRunId"], {"taskRunId": r["taskRunId"], "calls": 0, "unpricedCalls": 0, "_cost": 0.0})
1696
- run["calls"] += 1
1697
- if r.get("costUsd") is None:
1698
- run["unpricedCalls"] += 1
1699
- else:
1700
- run["_cost"] += float(r["costUsd"])
1701
- out = []
1702
- for key in sorted(projects):
1703
- row = projects[key]
1704
- runs = [_close_totals(run) for _, run in sorted(row.pop("_runs").items())]
1705
- row = _close_totals(row)
1706
- if runs:
1707
- row["taskRuns"] = runs
1708
- out.append(row)
1709
- return out
1710
-
1711
-
1712
- def _add_totals(row: Dict[str, Any], receipt: Dict[str, Any]) -> None:
1713
- row["calls"] += 1
1714
- if receipt.get("tokensMeasured") is False:
1715
- row["unmeasuredCalls"] = row.get("unmeasuredCalls", 0) + 1 # written only when above 0: absent is 0 (DEC-0091)
1716
- for name in ("inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
1717
- row[name] += int(receipt.get(name) or 0)
1718
- if receipt.get("costUsd") is None:
1719
- row["unpricedCalls"] += 1
1720
- else:
1721
- row["_cost"] += float(receipt["costUsd"])
1722
-
1723
-
1724
- def _close_totals(row: Dict[str, Any]) -> Dict[str, Any]:
1725
- cost = row.pop("_cost")
1726
- row["costUsd"] = None if row["calls"] and row["unpricedCalls"] == row["calls"] else round(cost, 6)
1727
- return row
1728
-
1729
-
1730
- # --- FAC-SEM-025 whole (DEC-0021, DEC-0065, CT-40, DEC-0076): the contract's `usageRules` -----------------------------
1731
-
1732
- _USAGE_WINDOW_DAYS = 31
1733
- _DAY_MS = 86_400_000
1734
-
1735
-
1736
- def _usage_count(value: Any) -> int:
1737
- return value if isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= 2 ** 53 - 1 else 0
1738
-
1739
-
1740
- def _usage_cost(value: Any) -> float:
1741
- return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) and value == value and value >= 0 and value != float("inf") else 0.0
1742
-
1743
-
1744
- def _exceeds(a: float, b: float) -> bool:
1745
- return a - b > 0.000001 + 1e-9 * max(abs(a), abs(b))
1746
-
1747
-
1748
- def _usd(value: float) -> str:
1749
- rounded = round(value * 1e6) / 1e6
1750
- return str(int(rounded)) if rounded == int(rounded) else repr(rounded)
1751
-
1752
-
1753
- def _utc_date_ms(value: Any) -> float:
1754
- match = re.fullmatch(r"([0-9]{4})-([0-9]{2})-([0-9]{2})", value, re.ASCII) if isinstance(value, str) else None
1755
- if not match:
1756
- return float("nan")
1757
- try:
1758
- day = _dt.date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
1759
- except ValueError:
1760
- return float("nan")
1761
- return float((day - _dt.date(1970, 1, 1)).days * _DAY_MS)
1762
-
1763
-
1764
- def _usage_list(value: Any) -> List[Dict[str, Any]]:
1765
- return [x for x in value if isinstance(x, dict)] if isinstance(value, list) else []
1766
-
1767
-
1768
- def _priced_row(row: Dict[str, Any], at: str) -> List[Dict[str, str]]:
1769
- calls, unpriced = _usage_count(row.get("calls")), _usage_count(row.get("unpricedCalls"))
1770
- flag = lambda path, message: [{"code": "FAC-SEM-025", "instancePath": path, "message": message}] # noqa: E731
1771
- if unpriced > calls:
1772
- return flag(at + "/unpricedCalls", "more unpriced calls than calls")
1773
- if _usage_count(row.get("unmeasuredCalls")) > calls: # DEC-0091: unknown token counts are some of the calls
1774
- return flag(at + "/unmeasuredCalls", "more calls with unknown tokens than calls")
1775
- if calls > 0 and unpriced == calls and row.get("costUsd", _MISSING_COST) is not None:
1776
- return flag(at + "/costUsd", "every call is unpriced, so the cost is unknown (null), not a number")
1777
- if unpriced < calls and row.get("costUsd", _MISSING_COST) is None:
1778
- return flag(at + "/costUsd", "priced calls carry a cost; null is only for a row with no priced call")
1779
- return []
1780
-
1781
-
1782
- _MISSING_COST = object()
1783
-
1784
-
1785
- def usage_findings(report: Any) -> List[Dict[str, str]]:
1786
- """FAC-SEM-025 over a usage report, in the contract's order (`src/usage-rules.ts`): the arithmetic of days and
1787
- models, the window (every date exists, not after `generatedAt`'s UTC date, at most 30 days before it), and the
1788
- project attribution (each project once by its normal form, each task run once; calls, priced calls and known costs
1789
- bounded). Malformed input answers findings or none, never an exception."""
1790
- import fabric_identifiers # noqa: E402
1791
- import fabric_estate # noqa: E402 - the strict RFC 3339 reader
1792
- value = report if isinstance(report, dict) else {}
1793
- findings: List[Dict[str, str]] = []
1794
- flag = lambda path, message: findings.append({"code": "FAC-SEM-025", "instancePath": path, "message": message}) # noqa: E731
1795
- days = _usage_list(value.get("days"))
1796
- # The arithmetic.
1797
- previous = ""
1798
- for i, day in enumerate(days):
1799
- at = "/days/%d" % i
1800
- date = day.get("date")
1801
- date = "" if date is None else (date if isinstance(date, str) else json.dumps(date))
1802
- if date <= previous:
1803
- flag(at + "/date", "days must run forward without a repeat (%s after %s)" % (date, previous))
1804
- previous = date
1805
- findings.extend(_priced_row(day, at))
1806
- models = _usage_list(day.get("byModel"))
1807
- for j, m in enumerate(models):
1808
- findings.extend(_priced_row(m, "%s/byModel/%d" % (at, j)))
1809
- if not models:
1810
- spent = any((day.get(f) or 0) > 0 for f in ("calls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens")
1811
- if isinstance(day.get(f) or 0, (int, float))) or (isinstance(day.get("costUsd"), (int, float)) and day["costUsd"] > 0)
1812
- if spent:
1813
- flag(at + "/byModel", "a day with calls, tokens or cost names the models they belong to")
1814
- continue
1815
- for field in ("calls", "unpricedCalls", "unmeasuredCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
1816
- total = sum(m.get(field) or 0 for m in models if isinstance(m.get(field) or 0, (int, float)))
1817
- if (day.get(field) or 0) != total:
1818
- flag("%s/%s" % (at, field), "the day's %s is not the sum of its models (%s ≠ %s)" % (field, day.get(field) or 0, total))
1819
- known = sum(m["costUsd"] for m in models if isinstance(m.get("costUsd"), (int, float)) and not isinstance(m.get("costUsd"), bool))
1820
- day_cost = day.get("costUsd")
1821
- if isinstance(day_cost, (int, float)) and not isinstance(day_cost, bool) and abs(day_cost - known) > 0.000001 + 1e-9 * max(abs(day_cost), abs(known)):
1822
- flag(at + "/costUsd", "the day's cost is not the sum of its models' known costs")
1823
- # The window.
1824
- if days:
1825
- generated = fabric_estate.estate_time(value.get("generatedAt"))
1826
- if generated != generated:
1827
- flag("/generatedAt", "generatedAt is an RFC 3339 date-time with an offset, so the window can be read")
1828
- else:
1829
- today = (generated // _DAY_MS) * _DAY_MS
1830
- oldest = today - (_USAGE_WINDOW_DAYS - 1) * _DAY_MS
1831
- for i, day in enumerate(days):
1832
- date = _utc_date_ms(day.get("date"))
1833
- if date != date:
1834
- flag("/days/%d/date" % i, "a day's date exists in its month")
1835
- elif date > today:
1836
- flag("/days/%d/date" % i, "a day is not after the UTC date of generatedAt")
1837
- elif date < oldest:
1838
- flag("/days/%d/date" % i, "a day is within the last %d UTC days of generatedAt" % _USAGE_WINDOW_DAYS)
1839
- # The projects.
1840
- if isinstance(value.get("byProject"), list):
1841
- seen: Dict[str, str] = {}
1842
- project_calls, project_cost = 0, 0.0
1843
- for p, project in enumerate(value["byProject"]):
1844
- if not isinstance(project, dict):
1845
- continue
1846
- at = "/byProject/%d" % p
1847
- normal = fabric_identifiers.normal_identifier(project.get("projectId"))
1848
- if normal is not None:
1849
- if normal in seen:
1850
- flag(at + "/projectId", "each project is one row: %s names the project %s already names" % (project["projectId"], seen[normal]))
1851
- else:
1852
- seen[normal] = project["projectId"]
1853
- project_calls += _usage_count(project.get("calls"))
1854
- project_cost += _usage_cost(project.get("costUsd"))
1855
- findings.extend(_priced_row(project, at))
1856
- runs: List[str] = []
1857
- run_calls, run_cost, run_priced = 0, 0.0, 0
1858
- for r, run in enumerate(_usage_list(project.get("taskRuns"))):
1859
- if isinstance(run.get("taskRunId"), str):
1860
- if run["taskRunId"] in runs:
1861
- flag("%s/taskRuns/%d/taskRunId" % (at, r), "each task run is one row within its project")
1862
- runs.append(run["taskRunId"])
1863
- run_calls += _usage_count(run.get("calls"))
1864
- run_cost += _usage_cost(run.get("costUsd"))
1865
- run_priced += max(0, _usage_count(run.get("calls")) - _usage_count(run.get("unpricedCalls")))
1866
- findings.extend(_priced_row(run, "%s/taskRuns/%d" % (at, r)))
1867
- project_priced = max(0, _usage_count(project.get("calls")) - _usage_count(project.get("unpricedCalls")))
1868
- known_cost = isinstance(project.get("costUsd"), (int, float)) and not isinstance(project.get("costUsd"), bool) and _usage_cost(project.get("costUsd")) == project.get("costUsd")
1869
- if run_calls > _usage_count(project.get("calls")):
1870
- flag(at + "/taskRuns", "a project's task runs hold more calls than the project (%d > %d)" % (run_calls, _usage_count(project.get("calls"))))
1871
- elif run_priced > project_priced:
1872
- flag(at + "/taskRuns", "a project's task runs hold more priced calls than the project (%d > %d)" % (run_priced, project_priced))
1873
- elif known_cost and _exceeds(run_cost, _usage_cost(project.get("costUsd"))):
1874
- flag(at + "/taskRuns", "a project's task runs cost more than the project (%s > %s)" % (_usd(run_cost), _usd(_usage_cost(project.get("costUsd")))))
1875
- day_calls = sum(_usage_count(d.get("calls")) for d in days)
1876
- if project_calls > day_calls:
1877
- flag("/byProject", "the projects hold more calls than the days (%d > %d)" % (project_calls, day_calls))
1878
- day_cost_total = sum(_usage_cost(d.get("costUsd")) for d in days)
1879
- if _exceeds(project_cost, day_cost_total):
1880
- flag("/byProject", "the projects cost more than the days (%s > %s)" % (_usd(project_cost), _usd(day_cost_total)))
1881
- return findings
1882
-
1883
-
1884
- # --- OpenRouter attribution (DEC-0065): the contract's `attributionHeaders`, off by default -----------------------------
1885
-
1886
- OPENROUTER_CATEGORIES = ("cli-agent", "ide-extension", "cloud-agent", "programming-app", "native-app-builder",
1887
- "creative-writing", "video-gen", "image-gen", "audio-gen", "writing-assistant", "general-chat",
1888
- "personal-agent", "legal", "roleplay", "game")
1889
- _ATTR_PRODUCT = re.compile(r"[a-z][a-z0-9-]{0,62}", re.ASCII)
1890
- _ATTR_TITLE = re.compile(r"[\x21-\x7e](?:[\x20-\x7e]{0,98}[\x21-\x7e])?", re.ASCII)
1891
- _ATTRIBUTION_HEADERS = ("http-referer", "referer", "x-openrouter-title", "x-title", "x-openrouter-categories")
1892
-
1893
-
1894
- def attribution_setting_findings(value: Any) -> List[Dict[str, str]]:
1895
- """FAC-SEM-098: a product's setting names a product slug, a printable title and at most two recognized categories."""
1896
- if not isinstance(value, dict):
1897
- return [{"code": "FAC-SEM-098", "instancePath": "", "message": "an attribution setting is an object"}]
1898
- findings: List[Dict[str, str]] = []
1899
- flag = lambda at, message: findings.append({"code": "FAC-SEM-098", "instancePath": at, "message": message}) # noqa: E731
1900
- if not (isinstance(value.get("product"), str) and _ATTR_PRODUCT.fullmatch(value["product"])):
1901
- flag("/product", "the product is a lower-case slug, the last segment of its product page")
1902
- if not (isinstance(value.get("title"), str) and _ATTR_TITLE.fullmatch(value["title"])):
1903
- flag("/title", "the title is the product's full name: printable ASCII, 1 to 100 characters, no space at either end")
1904
- if "categories" in value:
1905
- categories = value["categories"]
1906
- if not isinstance(categories, list):
1907
- flag("/categories", "categories are a list")
1908
- else:
1909
- if len(categories) > 2:
1910
- flag("/categories", "at most 2 categories per request")
1911
- seen: List[str] = []
1912
- for i, category in enumerate(categories):
1913
- if not (isinstance(category, str) and category in OPENROUTER_CATEGORIES):
1914
- flag("/categories/%d" % i, "a category is one OpenRouter recognizes; it drops others without an error")
1915
- elif category in seen:
1916
- flag("/categories/%d" % i, "each category once")
1917
- else:
1918
- seen.append(category)
1919
- return findings
1920
-
1921
-
1922
- def _is_openrouter(url: Any) -> bool:
1923
- """Whether a URL's WHATWG reading is https, host openrouter.ai, the default port and no credentials (`new URL`):
1924
- leading and trailing spaces and controls trimmed, tabs and newlines removed, backslashes read as slashes, the
1925
- slashes after the scheme optional, the host case-folded, port 443 the default."""
1926
- if not isinstance(url, str):
1927
- return False
1928
- text = url.strip("".join(chr(c) for c in range(0x21))).replace("\t", "").replace("\n", "").replace("\r", "")
1929
- if not text[:6].lower() == "https:":
1930
- return False
1931
- rest = text[6:].lstrip("/\\")
1932
- end = len(rest)
1933
- for stop in "/\\?#":
1934
- found = rest.find(stop)
1935
- if found != -1:
1936
- end = min(end, found)
1937
- authority = rest[:end]
1938
- if "@" in authority:
1939
- userinfo, authority = authority.rsplit("@", 1)
1940
- if userinfo not in ("", ":"):
1941
- return False
1942
- host, _, port = authority.partition(":")
1943
- if port and not (port.isdigit() and int(port) == 443):
1944
- return False
1945
- return host.lower() == "openrouter.ai"
1946
-
1947
-
1948
- def attribution_headers(setting: Any, request: Any) -> Dict[str, Any]:
1949
- """The headers a product adds to one request (DEC-0065, the contract's `attributionHeaders`), or none with the reason:
1950
- `no-setting` (attribution is off by default), `invalid-setting` (FAC-SEM-098), `not-openrouter` (only
1951
- `https://openrouter.ai`), `forwarded` (only traffic the product originates), `user-set` (a request that already
1952
- carries an attribution header keeps the user's own). Only `HTTP-Referer`, `X-OpenRouter-Title` and
1953
- `X-OpenRouter-Categories`; it never reads, moves or returns a key."""
1954
- req = request if isinstance(request, dict) else {}
1955
- if setting is None:
1956
- return {"headers": {}, "none": "no-setting"}
1957
- if attribution_setting_findings(setting):
1958
- return {"headers": {}, "none": "invalid-setting"}
1959
- if not _is_openrouter(req.get("url")):
1960
- return {"headers": {}, "none": "not-openrouter"}
1961
- if req.get("originated") is not True:
1962
- return {"headers": {}, "none": "forwarded"}
1963
- present = [str(name).lower() for name in req["headers"]] if isinstance(req.get("headers"), dict) else []
1964
- if any(name in _ATTRIBUTION_HEADERS for name in present):
1965
- return {"headers": {}, "none": "user-set"}
1966
- headers = {"HTTP-Referer": "https://passioncode.ai/%s/" % setting["product"], "X-OpenRouter-Title": setting["title"]}
1967
- if setting.get("categories"):
1968
- headers["X-OpenRouter-Categories"] = ",".join(setting["categories"])
1969
- return {"headers": headers}
1970
-
1971
-
1972
- def usage_report(receipts: Iterable[Dict[str, Any]], *, service_id: str, instance: str = "default",
1973
- now: Optional[_dt.datetime] = None,
1974
- budget: Optional[Dict[str, Any]] = None,
1975
- budgets: Optional[Sequence[Dict[str, Any]]] = None) -> Dict[str, Any]:
1976
- """The `service-usage.schema.json` answer: the last 31 UTC days, oldest first, per provider
1977
- and model. Days without calls are omitted. A row whose calls are all unpriced has
1978
- `costUsd: None`; totals are the sums of the model rows (FAC-SEM-025).
1979
-
1980
- ``budgets`` (DEC-0027) lists every spending limit the service applies, enforced or not. A
1981
- machine limit with a calendar ``period`` and no ``spentUsd`` is counted here from the receipts,
1982
- the way ``budget`` is, so the two never disagree; a per-order limit (``per_job``, ``approval``)
1983
- gets ``spentUsd: None``; every other limit states its own spend. A report whose limits break
1984
- FAC-SEM-031 is refused (``usage_limit_problems``)."""
1985
- now = now or _dt.datetime.now(_dt.timezone.utc)
1986
- receipts = list(receipts)
1987
- first = (now - _dt.timedelta(days=USAGE_DAYS - 1)).strftime("%Y-%m-%d")
1988
- last = now.strftime("%Y-%m-%d")
1989
- days: Dict[str, Dict[str, Dict[str, Any]]] = {}
1990
- bases: Dict[Tuple[str, str, str], set] = {}
1991
- for r in receipts:
1992
- date = str(r.get("at", ""))[:10]
1993
- if not (first <= date <= last):
1994
- continue
1995
- key = (r["provider"], r["model"], r.get("route") or "")
1996
- row = days.setdefault(date, {}).setdefault("%s\u0000%s\u0000%s" % key, {
1997
- "provider": r["provider"], "model": r["model"], "calls": 0, "unpricedCalls": 0,
1998
- "inputTokens": 0, "outputTokens": 0, "cacheReadTokens": 0, "cacheWriteTokens": 0, "_cost": 0.0})
1999
- if r.get("route"):
2000
- row["route"] = r["route"] # DEC-0046: rows of one model on two routes stay apart
2001
- _add_totals(row, r)
2002
- bases.setdefault((date,) + key, set()).add(r.get("costBasis") or "unknown")
2003
- out_days = []
2004
- for date in sorted(days):
2005
- models = []
2006
- for row in days[date].values():
2007
- seen = bases[(date, row["provider"], row["model"], row.get("route") or "")]
2008
- row["costBasis"] = next(iter(seen)) if len(seen) == 1 else "mixed"
2009
- models.append(_close_totals(row))
2010
- models.sort(key=lambda m: (m["provider"], m["model"], m.get("route") or ""))
2011
- day = {"date": date, "calls": 0, "unpricedCalls": 0, "inputTokens": 0, "outputTokens": 0,
2012
- "cacheReadTokens": 0, "cacheWriteTokens": 0, "_cost": 0.0}
2013
- for m in models:
2014
- for name in ("calls", "unpricedCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
2015
- day[name] += m[name]
2016
- if m.get("unmeasuredCalls"):
2017
- day["unmeasuredCalls"] = day.get("unmeasuredCalls", 0) + m["unmeasuredCalls"]
2018
- day["_cost"] += m["costUsd"] or 0.0
2019
- day = _close_totals(day)
2020
- day["byModel"] = models
2021
- out_days.append(day)
2022
- report: Dict[str, Any] = {"protocol": PROTOCOL, "service": {"id": service_id, "instance": instance},
2023
- "generatedAt": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "currency": "USD", "days": out_days}
2024
- projects = _by_project(receipts, first, last)
2025
- if projects:
2026
- report["byProject"] = projects
2027
- if budget is not None:
2028
- if budget.get("period") not in ("day", "month") or not isinstance(budget.get("limitUsd"), (int, float)) or budget["limitUsd"] <= 0:
2029
- raise ServiceError("budget is {period: day|month, limitUsd > 0}.")
2030
- report["budget"] = {"period": budget["period"], "limitUsd": float(budget["limitUsd"]),
2031
- "spentUsd": _spent_in(out_days, budget["period"], last)}
2032
- if budgets is not None:
2033
- limits = []
2034
- for given in budgets:
2035
- if not isinstance(given, dict):
2036
- raise ServiceError("each limit in budgets is an object")
2037
- limit = dict(given)
2038
- if "spentUsd" not in limit:
2039
- if limit.get("kind") in PER_ORDER_KINDS:
2040
- limit["spentUsd"] = None
2041
- elif limit.get("scope") == "machine" and limit.get("period") in ("day", "month"):
2042
- limit["spentUsd"] = _spent_in(out_days, limit["period"], last)
2043
- elif limit.get("scope") == "machine" and _number(limit.get("windowSeconds")):
2044
- limit["spentUsd"] = _spent_since(receipts, now - _dt.timedelta(seconds=limit["windowSeconds"]), now)
2045
- if isinstance(limit.get("limitUsd"), int) and not isinstance(limit.get("limitUsd"), bool):
2046
- limit["limitUsd"] = float(limit["limitUsd"])
2047
- limits.append(limit)
2048
- report["budgets"] = limits
2049
- problems = usage_limit_problems(report)
2050
- if problems:
2051
- raise ServiceError("the report's limits are not coherent: " + "; ".join(problems))
2052
- return report
2053
-
2054
-
2055
- def _spent_since(receipts: List[Dict[str, Any]], start: _dt.datetime, end: _dt.datetime) -> Optional[float]:
2056
- """Spend in a rolling window, from the receipts' own times; None when every call in it is unpriced."""
2057
- lo, hi = start.strftime("%Y-%m-%dT%H:%M:%SZ"), end.strftime("%Y-%m-%dT%H:%M:%SZ")
2058
- inside = [r for r in receipts if lo < str(r.get("at", "")) <= hi]
2059
- if inside and all(r.get("costUsd") is None for r in inside):
2060
- return None
2061
- return round(sum(r.get("costUsd") or 0.0 for r in inside), 6)
2062
-
2063
-
2064
- def _spent_in(out_days: List[Dict[str, Any]], period: str, last: str) -> Optional[float]:
2065
- """Spend in the current UTC day or month; None when every call in it is unpriced."""
2066
- prefix = last if period == "day" else last[:7]
2067
- window = [d for d in out_days if d["date"].startswith(prefix)]
2068
- unknown = bool(window) and all(d["costUsd"] is None for d in window)
2069
- return None if unknown else round(sum(d["costUsd"] or 0.0 for d in window), 6)
2070
-
2071
-
2072
- # --- every spending limit (DEC-0027, FAC-SEM-031) ---------------------------------
2073
-
2074
- LIMIT_SCOPES = ("machine", "project", "pool", "job")
2075
- PER_ORDER_KINDS = ("per_job", "approval")
2076
- _LIMIT_ID = re.compile(r"^[a-z0-9][a-z0-9._:-]{0,63}$")
2077
- _LIMIT_KIND = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
2078
- _LIMIT_FIELDS = ("id", "scope", "subject", "kind", "period", "windowSeconds", "since", "limitUsd", "spentUsd", "enforced", "tripped")
2079
- _TIMESTAMP = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-]([0-9]{2}):([0-9]{2}))")
2080
-
2081
-
2082
- def _is_timestamp(value: Any) -> bool:
2083
- """A JSON Schema `date-time` (RFC 3339): a real calendar date and time, `Z` or an offset."""
2084
- m = _TIMESTAMP.fullmatch(value) if isinstance(value, str) else None
2085
- if not m:
2086
- return False
2087
- year, month, day, hour, minute, second = (int(m.group(i)) for i in range(1, 7))
2088
- try:
2089
- _dt.date(year, month, day)
2090
- except ValueError:
2091
- return False
2092
- offset_ok = m.group(9) is None or (int(m.group(9)) <= 23 and int(m.group(10)) <= 59)
2093
- return hour <= 23 and minute <= 59 and second <= 60 and offset_ok
2094
-
2095
-
2096
- def _number(value: Any) -> bool:
2097
- return isinstance(value, (int, float)) and not isinstance(value, bool)
2098
-
2099
-
2100
- def usage_limit_problems(report: Dict[str, Any]) -> List[str]:
2101
- """The `budgets` of a usage report against `service-usage.schema.json` `$defs/limit` and FAC-SEM-031: unique
2102
- ids, a subject exactly for project and pool scope, one window that agrees with the kind, per-order limits with
2103
- no window and no spend, only a relative limit with a null `limitUsd`, a tripped limit enforced, and a legacy
2104
- `budget` that repeats one enforced machine limit. A breach (spent above the limit) is not a problem: a report
2105
- truthfully shows one, and a host shows it first. An unknown kind is never refused."""
2106
- problems: List[str] = []
2107
- limits = report.get("budgets")
2108
- if limits is None:
2109
- return problems
2110
- if not isinstance(limits, list) or len(limits) > 64:
2111
- return ["budgets is a list of at most 64 limits"]
2112
- seen = set()
2113
- for i, limit in enumerate(limits):
2114
- at = "/budgets/%d" % i
2115
- if not isinstance(limit, dict):
2116
- problems.append("%s is not an object" % at)
2117
- continue
2118
- for key in limit:
2119
- if key not in _LIMIT_FIELDS:
2120
- problems.append("%s/%s is not a limit field" % (at, key))
2121
- for key in ("id", "scope", "kind", "limitUsd", "spentUsd", "enforced"):
2122
- if key not in limit:
2123
- problems.append("%s is missing %s" % (at, key))
2124
- if "id" in limit and not (isinstance(limit["id"], str) and _LIMIT_ID.fullmatch(limit["id"])):
2125
- problems.append("%s/id must match %s" % (at, _LIMIT_ID.pattern))
2126
- if "scope" in limit and limit["scope"] not in LIMIT_SCOPES:
2127
- problems.append("%s/scope must be one of %s" % (at, ", ".join(LIMIT_SCOPES)))
2128
- if "subject" in limit and not (isinstance(limit["subject"], str) and 1 <= len(limit["subject"]) <= 128):
2129
- problems.append("%s/subject is 1 to 128 characters" % at)
2130
- if "kind" in limit and not (isinstance(limit["kind"], str) and _LIMIT_KIND.fullmatch(limit["kind"])):
2131
- problems.append("%s/kind must match %s" % (at, _LIMIT_KIND.pattern))
2132
- if "period" in limit and limit["period"] not in ("day", "month"):
2133
- problems.append("%s/period is day or month" % at)
2134
- if "windowSeconds" in limit and not (isinstance(limit["windowSeconds"], int) and not isinstance(limit["windowSeconds"], bool)
2135
- and limit["windowSeconds"] >= 1):
2136
- problems.append("%s/windowSeconds is a whole number of seconds, at least 1" % at)
2137
- if "since" in limit and not _is_timestamp(limit["since"]):
2138
- problems.append("%s/since is a UTC timestamp" % at)
2139
- if "limitUsd" in limit and not (limit["limitUsd"] is None or (_number(limit["limitUsd"]) and limit["limitUsd"] > 0)):
2140
- problems.append("%s/limitUsd is a dollar amount above zero, or null" % at)
2141
- if "spentUsd" in limit and not (limit["spentUsd"] is None or (_number(limit["spentUsd"]) and limit["spentUsd"] >= 0)):
2142
- problems.append("%s/spentUsd is a dollar amount, zero or more, or null" % at)
2143
- for key in ("enforced", "tripped"):
2144
- if key in limit and not isinstance(limit[key], bool):
2145
- problems.append("%s/%s is true or false" % (at, key))
2146
-
2147
- # FAC-SEM-031
2148
- ident = str(limit.get("id", ""))
2149
- if ident in seen:
2150
- problems.append("FAC-SEM-031 %s/id: limit id %s appears twice" % (at, ident))
2151
- seen.add(ident)
2152
- scope = limit.get("scope")
2153
- named = scope in ("project", "pool")
2154
- if named and "subject" not in limit:
2155
- problems.append("FAC-SEM-031 %s/subject: a %s limit names its %s in subject" % (at, scope, scope))
2156
- if not named and "subject" in limit:
2157
- problems.append("FAC-SEM-031 %s/subject: a %s limit has no subject" % (at, scope))
2158
- windows = [f for f in ("period", "windowSeconds", "since") if f in limit]
2159
- if len(windows) > 1:
2160
- problems.append("FAC-SEM-031 %s/%s: a limit has one window, not %s" % (at, windows[1], " and ".join(windows)))
2161
- kind = str(limit.get("kind", ""))
2162
- seconds = limit.get("windowSeconds") if _number(limit.get("windowSeconds")) else None
2163
- if kind in PER_ORDER_KINDS:
2164
- if windows:
2165
- problems.append("FAC-SEM-031 %s/%s: %s bounds one order and has no window" % (at, windows[0], kind))
2166
- if limit.get("spentUsd", 0) is not None:
2167
- problems.append("FAC-SEM-031 %s/spentUsd: %s bounds one order, so nothing is spent against it (null)" % (at, kind))
2168
- elif kind == "daily":
2169
- if not (limit.get("period") == "day" or seconds == 86400):
2170
- problems.append("FAC-SEM-031 %s/period: a daily limit counts the UTC day (period day) or the last 24 hours (windowSeconds 86400)" % at)
2171
- elif kind == "monthly":
2172
- if not (limit.get("period") == "month" or (seconds is not None and 28 * 86400 <= seconds <= 31 * 86400)):
2173
- problems.append("FAC-SEM-031 %s/period: a monthly limit counts the UTC month (period month) or a rolling 28–31 days (windowSeconds)" % at)
2174
- elif kind == "velocity":
2175
- if seconds is None:
2176
- problems.append("FAC-SEM-031 %s/windowSeconds: a velocity limit counts a rolling window (windowSeconds)" % at)
2177
- elif kind in ("pool", "emergency"):
2178
- if not windows:
2179
- problems.append("FAC-SEM-031 %s/period: a %s limit names the window it counts (period, windowSeconds or since)" % (at, kind))
2180
- known = kind in PER_ORDER_KINDS or kind in ("daily", "monthly", "pool", "emergency")
2181
- if known and "limitUsd" in limit and limit["limitUsd"] is None:
2182
- problems.append("FAC-SEM-031 %s/limitUsd: a %s limit is a dollar amount; only a relative limit (velocity) may leave it null" % (at, kind))
2183
- if limit.get("tripped") is True and limit.get("enforced") is not True:
2184
- problems.append("FAC-SEM-031 %s/tripped: a limit that is not enforced cannot have stopped work" % at)
2185
-
2186
- budget = report.get("budget")
2187
- if isinstance(budget, dict) and limits:
2188
- kind = "daily" if budget.get("period") == "day" else "monthly"
2189
-
2190
- def same(x: Any, y: Any) -> bool:
2191
- if x is None or y is None:
2192
- return x is y
2193
- return _number(x) and _number(y) and abs(x - y) <= 0.000001
2194
-
2195
- twin = any(isinstance(l, dict) and l.get("scope") == "machine" and l.get("enforced") is True and l.get("kind") == kind
2196
- and l.get("period") == budget.get("period") and same(l.get("limitUsd"), budget.get("limitUsd"))
2197
- and same(l.get("spentUsd"), budget.get("spentUsd")) for l in limits)
2198
- if not twin:
2199
- problems.append("FAC-SEM-031 /budget: budget repeats one enforced machine %s limit with period %s in budgets[], so old "
2200
- "and new readers agree" % (kind, budget.get("period")))
2201
- return problems
2202
-
2203
-
2204
- # --- the feed client (DEC-0025, FAC-SEM-036) --------------------------------------
2205
-
2206
- def token_header(auth: Any, token: str) -> Tuple[str, str]:
2207
- """The one header a client sends the service token in, as the descriptor's ``auth`` declares it: ``header``
2208
- defaults to Authorization and ``scheme`` to Bearer; ``none`` carries the raw token. Never assume
2209
- ``Authorization: Bearer`` (a feed client that did was refused on every poll and dropped every notify event)."""
2210
- declared = auth if isinstance(auth, dict) else {}
2211
- name = declared.get("header") if isinstance(declared.get("header"), str) else "Authorization"
2212
- scheme = declared.get("scheme") if isinstance(declared.get("scheme"), str) else "Bearer"
2213
- return name, token if scheme == "none" else "Bearer " + token
2214
-
2215
-
2216
- # --- settings backups (DEC-0025, FAC-SEM-035) -------------------------------------
2217
-
2218
- SETTINGS_BACKUP_FORMAT = "fabric-settings-backup/1"
2219
- SETTINGS_BACKUP_KEEP = 14
2220
- SETTINGS_BACKUP_MAX_AGE_SECONDS = 24 * 60 * 60
2221
- _STALE_TEMP_SECONDS = 60 * 60
2222
- _SAFE_INTEGER = 2 ** 53 - 1
2223
- _TABLE = re.compile(r"^[a-z][a-z0-9_]{0,62}$")
2224
- _SECRET_TABLE = re.compile(r"^((.+_)?(account_connections?|account_connection_drafts?|tokens?|secrets?|credentials?|passwords?|login_codes?)|(operator_)?sessions?)$")
2225
- _COLUMN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,62}$")
2226
- _SECRET_COLUMN = re.compile(r"^(?:.+_)?(?:[Tt]oken|[Ss]ecret|[Pp]assword|[Pp]assphrase|[Aa]pi_?[Kk]ey|[Pp]rivate_[Kk]ey|[Cc]ookie|[Ll]ogin_[Cc]ode|[Oo]tp|[Cc]redential)s?$")
2227
- _REASON = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
2228
- _SHA256 = re.compile(r"^[0-9a-f]{64}$")
2229
- _SURROGATE = re.compile("[\ud800-\udfff]")
2230
- _URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:\S*")
2231
- _MAX_CELL_CHARS = 1048576
2232
- _MAX_ROW_VALUES = 512
2233
-
2234
-
2235
- def _canonical_check(value: Any, at: str = "") -> None:
2236
- if value is None or isinstance(value, bool):
2237
- return
2238
- if isinstance(value, str):
2239
- # CT-25 (DEC-0052): Python cannot encode a lone surrogate as UTF-8, so no two languages agree on its bytes.
2240
- if _SURROGATE.search(value):
2241
- raise ServiceError("%s: a lone surrogate has no canonical form" % (at or "/"))
2242
- return
2243
- if isinstance(value, int):
2244
- if abs(value) > _SAFE_INTEGER:
2245
- raise ServiceError("%s: %d is outside ±(2^53 − 1) and has no canonical form; store it as a string" % (at or "/", value))
2246
- return
2247
- if isinstance(value, float):
2248
- raise ServiceError("%s: a fraction has no canonical form; a settings backup stores it as its decimal string" % (at or "/"))
2249
- if isinstance(value, list):
2250
- for i, item in enumerate(value):
2251
- _canonical_check(item, "%s/%d" % (at, i))
2252
- return
2253
- if isinstance(value, dict):
2254
- for key, item in value.items():
2255
- if not isinstance(key, str):
2256
- raise ServiceError("%s: object keys are strings" % (at or "/"))
2257
- _canonical_check(key, "%s (a key)" % (at or "/"))
2258
- _canonical_check(item, "%s/%s" % (at, key))
2259
- return
2260
- raise ServiceError("%s: %s is not a JSON value" % (at or "/", type(value).__name__))
2261
-
2262
-
2263
- def settings_canonical_json(value: Any) -> str:
2264
- """The settings canonical form (DEC-0025, DEC-0043): keys sorted by code point at every level, ", " and ": ",
2265
- strings escaped as JSON requires and nothing else, integers in decimal — Python's
2266
- ``json.dumps(value, sort_keys=True, ensure_ascii=False)``. Refuses a fraction, an integer outside ±(2^53 − 1)
2267
- and anything that is not JSON."""
2268
- _canonical_check(value)
2269
- return json.dumps(value, sort_keys=True, ensure_ascii=False)
2270
-
2271
-
2272
- def settings_backup_digest(tables: Any) -> str:
2273
- """The ``sha256`` a settings backup carries: SHA-256 of the UTF-8 canonical JSON of ``tables``."""
2274
- return hashlib.sha256(settings_canonical_json(tables).encode("utf-8")).hexdigest()
2275
-
2276
-
2277
- def settings_backup_problems(doc: Any) -> List[str]:
2278
- """Why a reader refuses this settings backup: its shape (``settings-backup.schema.json``) and FAC-SEM-035 —
2279
- the checksum is the digest of the tables, counts name exactly the tables and their rows, every row is as wide as
2280
- its columns, and every value has a canonical form. Empty means intact."""
2281
- if not isinstance(doc, dict):
2282
- return ["a settings backup is a JSON object"]
2283
- problems: List[str] = []
2284
- if doc.get("format") != SETTINGS_BACKUP_FORMAT:
2285
- problems.append("format must be %s, not %r: a reader refuses a format it does not know" % (SETTINGS_BACKUP_FORMAT, doc.get("format")))
2286
- for key in ("format", "service", "created_at", "reason", "sha256", "counts", "tables"):
2287
- if key not in doc:
2288
- problems.append("missing %s" % key)
2289
- for key in doc:
2290
- if key not in ("format", "service", "created_at", "reason", "sha256", "counts", "tables", "extensions"):
2291
- problems.append("%s is not a settings backup field" % key)
2292
- if "extensions" in doc:
2293
- if not isinstance(doc["extensions"], dict):
2294
- problems.append("extensions is an object")
2295
- else:
2296
- for key in doc["extensions"]:
2297
- if not _URI.fullmatch(key):
2298
- problems.append("extensions key %r is not a URI" % key)
2299
- service = doc.get("service")
2300
- if "service" in doc:
2301
- if not isinstance(service, dict) or set(service) != {"id", "instance", "version", "schema"}:
2302
- problems.append("service is {id, instance, version, schema}")
2303
- else:
2304
- if not _ID.fullmatch(str(service["id"])):
2305
- problems.append("service.id must match %s" % _ID.pattern)
2306
- if not _INSTANCE.fullmatch(str(service["instance"])):
2307
- problems.append("service.instance must match %s" % _INSTANCE.pattern)
2308
- if not (isinstance(service["version"], str) and len(service["version"]) <= 64):
2309
- problems.append("service.version is a string of at most 64 characters")
2310
- schema = service["schema"]
2311
- if not ((isinstance(schema, int) and not isinstance(schema, bool) and schema >= 0)
2312
- or (isinstance(schema, str) and 1 <= len(schema) <= 64)):
2313
- problems.append("service.schema is a whole number or a short string")
2314
- if "created_at" in doc and not _is_timestamp(doc["created_at"]):
2315
- problems.append("created_at is a UTC timestamp")
2316
- if "reason" in doc and not (isinstance(doc["reason"], str) and _REASON.fullmatch(doc["reason"])):
2317
- problems.append("reason must match %s" % _REASON.pattern)
2318
- if "sha256" in doc and not (isinstance(doc["sha256"], str) and _SHA256.fullmatch(doc["sha256"])):
2319
- problems.append("sha256 is 64 lowercase hex digits")
2320
- tables = doc.get("tables") if isinstance(doc.get("tables"), dict) else {}
2321
- counts = doc.get("counts") if isinstance(doc.get("counts"), dict) else {}
2322
- if "tables" in doc and not isinstance(doc.get("tables"), dict):
2323
- problems.append("tables is an object")
2324
- if "counts" in doc and not isinstance(doc.get("counts"), dict):
2325
- problems.append("counts is an object")
2326
- for name in list(tables) + [n for n in counts if n not in tables]:
2327
- if not _TABLE.fullmatch(name) or _SECRET_TABLE.fullmatch(name):
2328
- problems.append("table %s is never backed up: it can only hold connections, tokens, secrets, credentials, "
2329
- "passwords, login codes or sessions (or its name is not a table name)" % name)
2330
- for name, count in counts.items():
2331
- if not (isinstance(count, int) and not isinstance(count, bool) and count >= 0):
2332
- problems.append("counts.%s is a whole number" % name)
2333
- canonical = True
2334
- for name, table in tables.items():
2335
- if not isinstance(table, dict) or set(table) != {"columns", "rows"}:
2336
- problems.append("tables.%s is {columns, rows}" % name)
2337
- continue
2338
- columns, rows = table["columns"], table["rows"]
2339
- if not (isinstance(columns, list) and 1 <= len(columns) <= 512 and len(set(map(str, columns))) == len(columns)):
2340
- problems.append("tables.%s.columns is 1 to 512 distinct names" % name)
2341
- columns = columns if isinstance(columns, list) else []
2342
- for column in columns:
2343
- if not (isinstance(column, str) and _COLUMN.fullmatch(column)) or _SECRET_COLUMN.fullmatch(str(column)):
2344
- problems.append("column %s.%s is refused: its name says it holds a credential value (name the secret, "
2345
- "never store it: secret_name, token_slot)" % (name, column))
2346
- if not isinstance(rows, list):
2347
- problems.append("tables.%s.rows is a list" % name)
2348
- continue
2349
- width = len(columns)
2350
- for i, row in enumerate(rows):
2351
- at = "/tables/%s/rows/%d" % (name, i)
2352
- if not isinstance(row, list):
2353
- problems.append("%s is a list of values" % at)
2354
- continue
2355
- if len(row) != width:
2356
- problems.append("FAC-SEM-035 %s: row has %d values for %d columns" % (at, len(row), width))
2357
- if len(row) > _MAX_ROW_VALUES:
2358
- problems.append("%s holds more than %d values" % (at, _MAX_ROW_VALUES))
2359
- for j, cell in enumerate(row):
2360
- if isinstance(cell, float) or (isinstance(cell, int) and not isinstance(cell, bool) and abs(cell) > _SAFE_INTEGER):
2361
- canonical = False
2362
- problems.append("FAC-SEM-035 %s/%d: a fraction or an unsafe integer has no canonical form; store it as a string" % (at, j))
2363
- elif isinstance(cell, str) and _SURROGATE.search(cell):
2364
- canonical = False
2365
- problems.append("FAC-SEM-035 %s/%d: a string with a lone surrogate cannot be encoded as UTF-8" % (at, j))
2366
- elif isinstance(cell, str) and len(cell) > _MAX_CELL_CHARS:
2367
- problems.append("%s/%d is longer than %d characters" % (at, j, _MAX_CELL_CHARS))
2368
- elif not (cell is None or isinstance(cell, (bool, int, str))):
2369
- canonical = False
2370
- problems.append("%s/%d is not a JSON scalar" % (at, j))
2371
- if counts.get(name) != len(rows):
2372
- problems.append("FAC-SEM-035 /counts/%s: counts says %s for %d rows" % (name, counts.get(name, "nothing"), len(rows)))
2373
- for name in counts:
2374
- if name not in tables:
2375
- problems.append("FAC-SEM-035 /counts/%s: counts names %s, which the backup does not carry" % (name, name))
2376
- if canonical and isinstance(doc.get("tables"), dict):
2377
- try:
2378
- digest: Optional[str] = settings_backup_digest(tables)
2379
- except (ServiceError, UnicodeError, TypeError, ValueError) as exc: # a name no canonical form can carry
2380
- digest = None
2381
- problems.append("FAC-SEM-035 /tables: the tables have no canonical form (%s)" % exc)
2382
- if digest is not None and doc.get("sha256") != digest:
2383
- problems.append("FAC-SEM-035 /sha256: the checksum does not match the tables: the file is damaged and a reader refuses it")
2384
- return problems
2385
-
2386
-
2387
- def settings_backup_root(platform: str = sys.platform) -> Path:
2388
- """Where every service keeps its settings backups — outside its data directory (DEC-0025)."""
2389
- if platform == "darwin":
2390
- return Path.home() / "Library/Application Support/PassionCode"
2391
- if platform == "win32":
2392
- appdata = os.environ.get("APPDATA")
2393
- return (Path(appdata) if appdata else Path.home() / "AppData" / "Roaming") / "PassionCode"
2394
- return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "PassionCode"
2395
-
2396
-
2397
- def settings_backup_dir(service_id: str, instance: str = "default", root: Optional[Path] = None) -> Path:
2398
- _require_id(service_id)
2399
- if not (isinstance(instance, str) and _INSTANCE.fullmatch(instance)):
2400
- raise ServiceError("instance must match %s" % _INSTANCE.pattern)
2401
- return (root or settings_backup_root()) / "settings-backups" / service_id / instance
2402
-
2403
-
2404
- def make_settings_backup(*, service_id: str, instance: str = "default", version: str, schema: Any,
2405
- tables: Dict[str, Dict[str, Any]], reason: str, now: Optional[_dt.datetime] = None) -> Dict[str, Any]:
2406
- """A snapshot of the operator's decisions and bindings: the tables on the service's allow-list, each
2407
- ``{"columns": [...], "rows": [[...], ...]}`` of JSON scalars (a fraction as its decimal string). Refuses a file a
2408
- reader would refuse."""
2409
- now = now or _dt.datetime.now(_dt.timezone.utc)
2410
- doc = {"format": SETTINGS_BACKUP_FORMAT,
2411
- "service": {"id": service_id, "instance": instance, "version": version, "schema": schema},
2412
- "created_at": now.astimezone(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), "reason": reason,
2413
- "sha256": "", "counts": {name: len(table.get("rows") or []) for name, table in tables.items()}, "tables": tables}
2414
- try:
2415
- doc["sha256"] = settings_backup_digest(tables)
2416
- except ServiceError as exc:
2417
- raise ServiceError("this snapshot has no canonical form: %s" % exc) from None
2418
- problems = settings_backup_problems(doc)
2419
- if problems:
2420
- raise ServiceError("this snapshot would be refused: " + "; ".join(problems))
2421
- return doc
2422
-
2423
-
2424
- def _backup_name(doc: Dict[str, Any]) -> str:
2425
- stamp = doc["created_at"].replace("-", "").replace(":", "")
2426
- head, _, frac = stamp.rstrip("Z").partition(".")
2427
- return "settings-%s%sZ.json" % (head, (frac + "000000")[:6])
2428
-
2429
-
2430
- def settings_backups(directory: Path) -> List[Path]:
2431
- """The snapshot files, newest first (their names sort in time order)."""
2432
- directory = Path(directory)
2433
- if not directory.is_dir():
2434
- return []
2435
- return sorted((p for p in directory.iterdir() if re.fullmatch(r"settings-[0-9]{8}T[0-9]{12}Z\.json", p.name)), reverse=True)
2436
-
2437
-
2438
- def _no_constant(name: str) -> Any:
2439
- raise ValueError("%s is not JSON" % name)
2440
-
2441
-
2442
- def read_settings_backup(path: Path) -> Dict[str, Any]:
2443
- """A snapshot a reader may trust, or ServiceError naming why it is refused (FAC-SEM-035)."""
2444
- try:
2445
- doc = json.loads(Path(path).read_text(encoding="utf-8"), parse_constant=_no_constant)
2446
- except (OSError, ValueError) as exc:
2447
- raise ServiceError("settings backup %s cannot be read: %s" % (path, exc)) from None
2448
- problems = settings_backup_problems(doc)
2449
- if problems:
2450
- raise ServiceError("settings backup %s is refused: %s" % (path, "; ".join(problems)))
2451
- return doc
2452
-
2453
-
2454
- def write_settings_backup(doc: Dict[str, Any], directory: Path, *, keep: int = SETTINGS_BACKUP_KEEP) -> Optional[Path]:
2455
- """Write a snapshot made by ``make_settings_backup``: atomically, 0600 in a 0700 directory, named by its time.
2456
- A temporary file an interrupted write left is removed first; a snapshot whose checksum equals the newest file's
2457
- is not written again (None). After a write, all but the ``keep`` newest are removed (at least 14)."""
2458
- problems = settings_backup_problems(doc)
2459
- if problems:
2460
- raise ServiceError("this snapshot would be refused: " + "; ".join(problems))
2461
- directory = Path(directory)
2462
- directory.mkdir(parents=True, exist_ok=True)
2463
- ensure_private_dir(directory)
2464
- stale = time.time() - _STALE_TEMP_SECONDS
2465
- for left in directory.iterdir():
2466
- # Only a temporary file an interrupted write left: one a concurrent writer is still filling is younger.
2467
- try:
2468
- if left.name.startswith(".settings-") and left.stat().st_mtime < stale:
2469
- left.unlink()
2470
- except OSError:
2471
- pass
2472
- existing = settings_backups(directory)
2473
- if existing:
2474
- try:
2475
- if read_settings_backup(existing[0])["sha256"] == doc["sha256"]:
2476
- return None
2477
- except Exception: # noqa: BLE001 - a damaged newest file is no reason to skip a good snapshot
2478
- pass
2479
- path = directory / _backup_name(doc)
2480
- atomic_write(path, json.dumps(doc, ensure_ascii=False, indent=2).encode("utf-8") + b"\n", 0o600)
2481
- # The newest by name, but never the one just written: a clock that stepped back must not delete it.
2482
- for old in [p for p in settings_backups(directory) if p != path][max(keep, SETTINGS_BACKUP_KEEP) - 1:]:
2483
- old.unlink()
2484
- return path
2485
-
2486
-
2487
- def backup_due(directory: Path, now: Optional[_dt.datetime] = None) -> bool:
2488
- """True when there is no snapshot or the newest is older than 24 hours: take one at start or on the daily tick."""
2489
- newest = settings_backups(directory)
2490
- if not newest:
2491
- return True
2492
- try:
2493
- stamp = _dt.datetime.strptime(newest[0].name[len("settings-"):-len("Z.json")], "%Y%m%dT%H%M%S%f").replace(tzinfo=_dt.timezone.utc)
2494
- except ValueError:
2495
- return True # a name no calendar has: take a fresh snapshot rather than trust it
2496
- return ((now or _dt.datetime.now(_dt.timezone.utc)) - stamp).total_seconds() > SETTINGS_BACKUP_MAX_AGE_SECONDS
2497
-
2498
-
2499
- def newest_intact_backup(directory: Path) -> Tuple[Optional[Path], Optional[Dict[str, Any]], List[Tuple[Path, str]]]:
2500
- """The source of an automatic restore: the newest snapshot that is intact and has content. Damaged ones are
2501
- skipped and returned with the reason, for the restore report."""
2502
- skipped: List[Tuple[Path, str]] = []
2503
- for path in settings_backups(directory):
2504
- try:
2505
- doc = read_settings_backup(path)
2506
- except Exception as exc: # noqa: BLE001 - one damaged file never stops the scan
2507
- skipped.append((path, str(exc)))
2508
- continue
2509
- if any(doc["counts"].values()):
2510
- return path, doc, skipped
2511
- return None, None, skipped
2512
-
2513
-
2514
- def restore_plan(doc: Dict[str, Any], current: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
2515
- """What restoring ``doc`` would do — it adds and never overwrites (DEC-0025). ``current`` describes the database
2516
- now, per table: ``columns`` (all of them), ``key`` (the primary key columns), ``required`` (columns without a
2517
- default) and ``existing`` (the primary keys present, as tuples). Per table: the rows to insert (dicts over the
2518
- columns both sides have), how many are already present, the snapshot columns dropped, the current columns left
2519
- to their defaults, the required columns the snapshot lacks, and every row refused with its key and reason.
2520
- ``unknownTables`` names snapshot tables the service no longer has. The service applies ``insert`` and adds any
2521
- row its database refuses to ``refused``; a manual restore shows this plan first and applying it is the operator's act."""
2522
- problems = settings_backup_problems(doc)
2523
- if problems:
2524
- raise ServiceError("a damaged snapshot is never restored: " + "; ".join(problems))
2525
- plan: Dict[str, Any] = {"source": {"created_at": doc["created_at"], "sha256": doc["sha256"]}, "tables": {}, "unknownTables": []}
2526
- for name, table in doc["tables"].items():
2527
- now_table = current.get(name)
2528
- if now_table is None:
2529
- plan["unknownTables"].append(name)
2530
- continue
2531
- columns = list(now_table.get("columns") or [])
2532
- key = list(now_table.get("key") or [])
2533
- required = list(now_table.get("required") or [])
2534
- existing = {tuple(k) for k in now_table.get("existing") or []}
2535
- shared = [c for c in table["columns"] if c in columns]
2536
- missing = [c for c in required if c not in table["columns"]]
2537
- entry = {"insert": [], "present": 0, "droppedColumns": [c for c in table["columns"] if c not in columns],
2538
- "defaultedColumns": [c for c in columns if c not in table["columns"]], "missingRequired": missing, "refused": []}
2539
- planned = set()
2540
- for row in table["rows"]:
2541
- values = dict(zip(table["columns"], row))
2542
- ident = [values.get(c) for c in key]
2543
- if any(c not in table["columns"] for c in key):
2544
- entry["refused"].append({"key": ident, "reason": "the primary key is not in the snapshot"})
2545
- elif tuple(ident) in planned:
2546
- entry["refused"].append({"key": ident, "reason": "the snapshot holds this key twice"})
2547
- elif tuple(ident) in existing:
2548
- entry["present"] += 1
2549
- elif missing:
2550
- entry["refused"].append({"key": ident, "reason": "required column %s is not in the snapshot" % ", ".join(missing)})
2551
- else:
2552
- planned.add(tuple(ident))
2553
- entry["insert"].append({c: values[c] for c in shared})
2554
- plan["tables"][name] = entry
2555
- return plan
2556
-
2557
-
2558
- class JsonlUsageLedger:
2559
- """Usage receipts for services that keep none yet: append-only JSON lines, mode 0600, pruned
2560
- to the reported window so the file stays bounded (LC-12). One writer process — the service,
2561
- which holds its instance lock; the lock here orders that process's threads."""
2562
-
2563
- def __init__(self, path: Path):
2564
- self.path = Path(path)
2565
- self._lock = threading.Lock()
2566
-
2567
- def record(self, receipt: Dict[str, Any]) -> None:
2568
- line = json.dumps(receipt, separators=(",", ":")) + "\n"
2569
- with self._lock:
2570
- ensure_private_dir(self.path.parent)
2571
- fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT | os.O_APPEND | _O_BINARY, 0o600)
2572
- try:
2573
- size = os.fstat(fd).st_size
2574
- if size and _read_at(fd, 1, size - 1) != b"\n":
2575
- line = "\n" + line # a killed writer left a torn line: never glue a receipt to it
2576
- os.write(fd, line.encode("utf-8"))
2577
- finally:
2578
- os.close(fd)
2579
-
2580
- def receipts(self) -> List[Dict[str, Any]]:
2581
- if not self.path.exists():
2582
- return []
2583
- out = []
2584
- for line in self.path.read_text(encoding="utf-8").splitlines():
2585
- try:
2586
- out.append(json.loads(line))
2587
- except ValueError:
2588
- continue # a torn last line from a killed writer is skipped, not fatal
2589
- return out
2590
-
2591
- def prune(self, now: Optional[_dt.datetime] = None) -> int:
2592
- """Drop receipts older than the reported window; returns how many were dropped."""
2593
- now = now or _dt.datetime.now(_dt.timezone.utc)
2594
- first = (now - _dt.timedelta(days=USAGE_DAYS - 1)).strftime("%Y-%m-%d")
2595
- with self._lock:
2596
- every = self.receipts()
2597
- kept = [r for r in every if str(r.get("at", ""))[:10] >= first]
2598
- dropped = len(every) - len(kept)
2599
- if dropped:
2600
- atomic_write(self.path, "".join(json.dumps(r, separators=(",", ":")) + "\n" for r in kept).encode("utf-8"))
2601
- return dropped
2602
-
2603
- def report(self, *, service_id: str, instance: str = "default", now: Optional[_dt.datetime] = None,
2604
- budget: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
2605
- return usage_report(self.receipts(), service_id=service_id, instance=instance, now=now, budget=budget)
2606
-
2607
-
2608
- # --- logs (LC-12) ---------------------------------------------------------------
2609
-
2610
- class RotatingLog:
2611
- """One structured log: JSON lines rotated by size (5 x 5 MB by default), files 0600 in a
2612
- 0700 directory. ``service.jsonl`` -> ``.1`` ... ``.<backups>``; the oldest falls off.
2613
-
2614
- Never pass a token, a cookie, a login code or a request body that may carry one."""
2615
-
2616
- def __init__(self, path: Path, max_bytes: int = LOG_MAX_BYTES, backups: int = LOG_BACKUPS):
2617
- if max_bytes < 256 or backups < 1:
2618
- raise ServiceError("A rotating log needs max_bytes >= 256 and at least one backup.")
2619
- self.path = Path(path)
2620
- self.max_bytes = max_bytes
2621
- self.backups = backups
2622
- self._lock = threading.Lock()
2623
-
2624
- def _line(self, record: Dict[str, Any]) -> bytes:
2625
- data = (json.dumps(record, ensure_ascii=False) + "\n").encode()
2626
- if len(data) <= self.max_bytes:
2627
- return data
2628
- record = dict(record, truncated=True)
2629
- message = str(record.get("message", ""))
2630
- while message:
2631
- message = message[: len(message) // 2]
2632
- record["message"] = message
2633
- data = (json.dumps(record, ensure_ascii=False) + "\n").encode()
2634
- if len(data) <= self.max_bytes:
2635
- return data
2636
- return (json.dumps({"at": record.get("at"), "level": record.get("level"), "message": "",
2637
- "truncated": True}) + "\n").encode()
2638
-
2639
- def write(self, level: str, message: str, **fields: Any) -> None:
2640
- record: Dict[str, Any] = {"at": now_iso(), "level": str(level), "message": str(message)}
2641
- record.update(fields)
2642
- data = self._line(record)
2643
- with self._lock:
2644
- ensure_private_dir(self.path.parent)
2645
- try:
2646
- size = self.path.stat().st_size
2647
- except FileNotFoundError:
2648
- size = 0
2649
- if size and size + len(data) > self.max_bytes:
2650
- self._rotate()
2651
- fd = os.open(str(self.path), os.O_WRONLY | os.O_APPEND | os.O_CREAT | _O_BINARY, 0o600)
2652
- try:
2653
- os.write(fd, data)
2654
- finally:
2655
- os.close(fd)
2656
-
2657
- def _rotate(self) -> None:
2658
- for index in range(self.backups - 1, 0, -1):
2659
- older = Path("%s.%d" % (self.path, index))
2660
- if older.exists():
2661
- os.replace(older, "%s.%d" % (self.path, index + 1))
2662
- os.replace(self.path, "%s.1" % self.path)
2663
-
2664
-
2665
- def cap_stdout_log(path: Path, max_bytes: int = LOG_MAX_BYTES) -> bool:
2666
- """Call once at start: a launchd stdout file over ``max_bytes`` is copied to ``<path>.1`` and
2667
- truncated in place. launchd holds the file open for append, so a rename would leave it
2668
- writing to the old name; truncation keeps its descriptor valid. Returns True when capped."""
2669
- path = Path(path)
2670
- try:
2671
- size = path.stat().st_size
2672
- except FileNotFoundError:
2673
- return False
2674
- if size <= max_bytes:
2675
- return False
2676
- backup = Path("%s.1" % path)
2677
- shutil.copyfile(path, backup)
2678
- os.chmod(backup, 0o600)
2679
- with open(path, "r+b") as handle:
2680
- handle.truncate(0)
2681
- return True
2682
-
2683
-
2684
- # --- shutdown (LC-01) -------------------------------------------------------------
2685
-
2686
- class Stopping(ServiceError):
2687
- """The service is stopping and takes no new work."""
2688
-
2689
-
2690
- class Drain:
2691
- """SIGTERM/SIGINT: stop taking new work, drain in-flight work until ``deadline``, then hand
2692
- over with ``on_stop(drained)``. A hand-over that hangs is cut by a hard exit
2693
- (``EXIT_HARD_STOP``) ``grace`` seconds later, so quit reaches exit inside the lifecycle bound.
2694
-
2695
- drain = fs.Drain()
2696
- drain.install(lambda drained: server.shutdown(),
2697
- on_stopping=lambda: log.append("service.stopping", "info", "Stopping."))
2698
- with drain.work(): # raises fs.Stopping once a stop has begun
2699
- ...
2700
- """
2701
-
2702
- def __init__(self, deadline: float = DRAIN_SECONDS, grace: float = DRAIN_GRACE_SECONDS):
2703
- self.deadline = deadline
2704
- self.grace = grace
2705
- self._cond = threading.Condition()
2706
- self._inflight = 0
2707
- self._stopping = False
2708
-
2709
- @property
2710
- def stopping(self) -> bool:
2711
- return self._stopping
2712
-
2713
- @property
2714
- def inflight(self) -> int:
2715
- return self._inflight
2716
-
2717
- @contextlib.contextmanager
2718
- def work(self):
2719
- with self._cond:
2720
- if self._stopping:
2721
- raise Stopping("The service is stopping and takes no new work.")
2722
- self._inflight += 1
2723
- try:
2724
- yield
2725
- finally:
2726
- with self._cond:
2727
- self._inflight -= 1
2728
- self._cond.notify_all()
2729
-
2730
- def request_stop(self) -> bool:
2731
- """Refuse new work from now on; False when a stop had already begun."""
2732
- with self._cond:
2733
- if self._stopping:
2734
- return False
2735
- self._stopping = True
2736
- return True
2737
-
2738
- def wait(self, timeout: Optional[float] = None) -> bool:
2739
- """Wait for in-flight work to finish; True when it did inside ``timeout``."""
2740
- end = None if timeout is None else time.monotonic() + timeout
2741
- with self._cond:
2742
- while self._inflight > 0:
2743
- left = None if end is None else end - time.monotonic()
2744
- if left is not None and left <= 0:
2745
- return False
2746
- self._cond.wait(left)
2747
- return True
2748
-
2749
- def install(self, on_stop: Callable[[bool], None], *, on_stopping: Optional[Callable[[], None]] = None,
2750
- signals: Sequence[int] = (signal.SIGTERM, signal.SIGINT)) -> "Drain":
2751
- """Install the handlers (main thread only). A second signal changes nothing: the hard-exit
2752
- timer already holds the deadline."""
2753
- def handler(_signum: int, _frame: Any) -> None:
2754
- if self.request_stop():
2755
- threading.Thread(target=self._stop, args=(on_stopping, on_stop), daemon=True).start()
2756
-
2757
- for sig in signals:
2758
- signal.signal(sig, handler)
2759
- return self
2760
-
2761
- def _stop(self, on_stopping: Optional[Callable[[], None]], on_stop: Callable[[bool], None]) -> None:
2762
- hard = threading.Timer(self.deadline + self.grace, os._exit, args=(EXIT_HARD_STOP,))
2763
- hard.daemon = True
2764
- hard.start()
2765
- if on_stopping is not None:
2766
- try:
2767
- on_stopping()
2768
- except Exception: # a failing stop notice must not stop the drain
2769
- traceback.print_exc()
2770
- on_stop(self.wait(self.deadline))
2771
-
2772
-
2773
- # --- operator login ----------------------------------------------------------
2774
-
2775
- class LoginCodes:
2776
- """Single-use login codes (<=120 s), recorded as used BEFORE they are honoured, and operator
2777
- sessions with the DEC-0039 lifetime: a session ends 12 h after its last use, 7 days after the code
2778
- was redeemed, on logout, and when the descriptor token rotates. The server enforces all four: the
2779
- cookie carries a signed claim (session id, sign-in time, token revision) and the code store keeps a
2780
- record per session (last use, logout). Rotating the key with revoke_all() ends every session."""
2781
-
2782
- def __init__(self, state_dir: Optional[Path], ttl: int = LOGIN_CODE_TTL_SECONDS, *,
2783
- store: Optional["MemoryCodeStore"] = None, key: Optional[bytes] = None,
2784
- token: "Optional[str | Callable[[], str]]" = None,
2785
- idle_seconds: int = OPERATOR_SESSION_IDLE_SECONDS,
2786
- max_seconds: int = OPERATOR_SESSION_MAX_SECONDS,
2787
- clock: Callable[[], float] = time.time):
2788
- # A local service keeps codes, sessions and the key in files. An online one (DEC-0019) usually has
2789
- # no durable disk: pass state_dir=None with store=MemoryCodeStore() and key= bytes from a platform
2790
- # secret. A memory store forgets codes AND sessions on a restart, so a deploy signs the operator
2791
- # out (the host signs in again on the page's 401); pass a durable store to keep sessions.
2792
- # token= is the descriptor token, or a callable returning the current one: a session begun under
2793
- # one token is refused once it rotates. A service MAY shorten either bound, never lengthen one.
2794
- self.ttl = min(ttl, LOGIN_CODE_TTL_SECONDS)
2795
- self.idle_seconds = _session_bound(idle_seconds, OPERATOR_SESSION_IDLE_SECONDS)
2796
- self.max_seconds = _session_bound(max_seconds, OPERATOR_SESSION_MAX_SECONDS)
2797
- # One lock around every load-modify-save, so a concurrent refresh never saves over a logout or a
2798
- # burned code (a threading server checks sessions on several threads; the instance lock keeps
2799
- # other processes out of the state directory).
2800
- self._lock = threading.RLock()
2801
- self._store = store
2802
- self._fixed_key = key
2803
- self._token = token
2804
- self._clock = clock
2805
- if state_dir is not None:
2806
- self.dir = ensure_private_dir(Path(state_dir))
2807
- self._key_path = self.dir / "session.key"
2808
- self._codes_path = self.dir / "login-codes.json"
2809
- elif store is None or key is None:
2810
- raise ServiceError("LoginCodes without a state directory needs store= and key=.")
2811
- if key is not None and len(key) < 32:
2812
- raise ServiceError("the session key must be at least 32 bytes.")
2813
-
2814
- def _key(self) -> bytes:
2815
- if self._fixed_key is not None:
2816
- return bytes(self._fixed_key)
2817
- if not self._key_path.exists():
2818
- atomic_write(self._key_path, secrets.token_bytes(32), 0o600)
2819
- return self._key_path.read_bytes()
2820
-
2821
- def _load(self) -> Dict[str, Any]:
2822
- if self._store is not None:
2823
- return self._store.load()
2824
- try:
2825
- return json.loads(self._codes_path.read_text())
2826
- except (OSError, ValueError):
2827
- return {}
2828
-
2829
- def _save(self, codes: Dict[str, Any]) -> None:
2830
- horizon = self._clock() - 3600
2831
- codes = {k: v for k, v in codes.items() if v.get("expires", 0) > horizon}
2832
- if self._store is not None:
2833
- self._store.save(codes)
2834
- return
2835
- atomic_write(self._codes_path, json.dumps(codes).encode(), 0o600)
2836
-
2837
- def issue(self) -> Dict[str, str]:
2838
- with self._lock:
2839
- code = secrets.token_urlsafe(24)
2840
- expires = self._clock() + self.ttl
2841
- codes = self._load()
2842
- codes[hashlib.sha256(code.encode()).hexdigest()] = {"expires": expires, "used": False}
2843
- self._save(codes)
2844
- at = _dt.datetime.fromtimestamp(expires, _dt.timezone.utc).replace(microsecond=0)
2845
- return {"url": "/fabric/v1/login?code=" + code, "expiresAt": at.isoformat().replace("+00:00", "Z")}
2846
-
2847
- def redeem(self, code: Optional[str]) -> Optional[str]:
2848
- """Return a session cookie value, or None. The code is burned even when it has expired."""
2849
- with self._lock:
2850
- if not code or not _CODE.match(code):
2851
- return None
2852
- digest = hashlib.sha256(code.encode()).hexdigest()
2853
- codes = self._load()
2854
- entry = codes.get(digest)
2855
- if not entry or entry.get("used"):
2856
- return None
2857
- entry["used"] = True
2858
- self._save(codes)
2859
- now = self._clock()
2860
- if entry.get("expires", 0) < now:
2861
- return None
2862
- session_id = secrets.token_urlsafe(18)
2863
- signed_in = int(now)
2864
- codes[self._record_key(session_id)] = {"expires": signed_in + self.max_seconds,
2865
- "signedInAt": signed_in, "lastUsedAt": now}
2866
- self._save(codes)
2867
- claim = "v2.%s.%d.%s" % (session_id, signed_in, self._token_revision())
2868
- return claim + "." + self._mac(claim)
2869
-
2870
- def session_valid(self, cookie_value: Optional[str]) -> bool:
2871
- """True while the session is live; a use within the bounds refreshes the idle bound."""
2872
- with self._lock:
2873
- parsed = self._parse(cookie_value)
2874
- if parsed is None:
2875
- return False
2876
- session_id, signed_in = parsed
2877
- now = self._clock()
2878
- if now >= signed_in + self.max_seconds:
2879
- return False
2880
- codes = self._load()
2881
- record = codes.get(self._record_key(session_id))
2882
- if not record or record.get("signedInAt") != signed_in or record.get("loggedOutAt") is not None:
2883
- return False
2884
- last = float(record.get("lastUsedAt", 0))
2885
- if now >= last + self.idle_seconds:
2886
- return False
2887
- if now - last >= min(60, max(1, self.idle_seconds // 10)):
2888
- record["lastUsedAt"] = now
2889
- self._save(codes)
2890
- return True
2891
-
2892
- def logout(self, cookie_value: Optional[str]) -> bool:
2893
- """End this session on the server: every copy of the cookie is refused from now on. Clear the
2894
- browser's copy with expired_session_cookie_header(). False when there was no live record."""
2895
- with self._lock:
2896
- parsed = self._parse(cookie_value)
2897
- if parsed is None:
2898
- return False
2899
- codes = self._load()
2900
- record = codes.get(self._record_key(parsed[0]))
2901
- if not record or record.get("signedInAt") != parsed[1] or record.get("loggedOutAt") is not None:
2902
- return False
2903
- record["loggedOutAt"] = self._clock()
2904
- self._save(codes)
2905
- return True
2906
-
2907
- def _parse(self, cookie_value: Optional[str]) -> Optional[Tuple[str, int]]:
2908
- """The signed claim of a cookie under the current key and token, or None."""
2909
- if not isinstance(cookie_value, str) or not cookie_value.isascii():
2910
- return None
2911
- parts = cookie_value.split(".")
2912
- if len(parts) != 5 or parts[0] != "v2" or not parts[1] or not parts[2].isdigit():
2913
- return None
2914
- claim, mac = ".".join(parts[:4]), parts[4]
2915
- if not hmac.compare_digest(mac, self._mac(claim)):
2916
- return None
2917
- if not hmac.compare_digest(parts[3], self._token_revision()):
2918
- return None
2919
- return parts[1], int(parts[2])
2920
-
2921
- def _mac(self, claim: str) -> str:
2922
- return hmac.new(self._key(), claim.encode(), hashlib.sha256).hexdigest()
2923
-
2924
- def _token_revision(self) -> str:
2925
- token = self._token() if callable(self._token) else self._token
2926
- if not token:
2927
- return "-"
2928
- return hmac.new(self._key(), b"token-revision\0" + token.encode(), hashlib.sha256).hexdigest()[:16]
2929
-
2930
- @staticmethod
2931
- def _record_key(session_id: str) -> str:
2932
- return "s:" + hashlib.sha256(session_id.encode()).hexdigest()
2933
-
2934
- def revoke_all(self) -> None:
2935
- if self._fixed_key is not None:
2936
- raise ServiceError("a platform-held session key is rotated on the platform, not here.")
2937
- atomic_write(self._key_path, secrets.token_bytes(32), 0o600)
2938
-
2939
-
2940
- def _session_bound(value: Any, longest: int) -> int:
2941
- """A session bound in seconds: a finite number, never longer than the contract's (DEC-0039)."""
2942
- if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value or value in (float("inf"), float("-inf")):
2943
- raise ServiceError("a session bound must be a finite number of seconds.")
2944
- return min(int(value), longest)
2945
-
2946
-
2947
- class MemoryCodeStore:
2948
- """DEC-0019: a login-code store held in memory — for an online service with no durable disk."""
2949
-
2950
- def __init__(self) -> None:
2951
- self._codes: Dict[str, Any] = {}
2952
-
2953
- def load(self) -> Dict[str, Any]:
2954
- return json.loads(json.dumps(self._codes))
2955
-
2956
- def save(self, codes: Dict[str, Any]) -> None:
2957
- self._codes = json.loads(json.dumps(codes))
2958
-
2959
-
2960
- SESSION_COOKIE = "fabric_session"
2961
- REMOTE_SESSION_COOKIE = "__Host-fabric_session"
2962
-
2963
-
2964
- def session_cookie_header(value: str, max_age: int = OPERATOR_SESSION_MAX_SECONDS) -> str:
2965
- """The cookie lives no longer than the absolute bound (DEC-0039); the server ends it sooner."""
2966
- return "%s=%s; Path=/; HttpOnly; SameSite=Strict; Max-Age=%d" % (SESSION_COOKIE, value, max_age)
2967
-
2968
-
2969
- def remote_session_cookie_header(value: str, max_age: int = OPERATOR_SESSION_MAX_SECONDS) -> str:
2970
- """DEC-0019: the remote cookie — __Host- name, Secure, no Domain, Path=/."""
2971
- return "%s=%s; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=%d" % (REMOTE_SESSION_COOKIE, value, max_age)
2972
-
2973
-
2974
- def expired_session_cookie_header(remote: bool = False) -> str:
2975
- """The header that clears the browser's session cookie, sent with a logout."""
2976
- return (remote_session_cookie_header if remote else session_cookie_header)("", 0)
2977
-
2978
-
2979
- def check_remote_request(origin: str, host: Optional[str], request_origin: Optional[str] = None,
2980
- sec_fetch_site: Optional[str] = None, forwarded_proto: Optional[str] = None) -> Optional[str]:
2981
- """DEC-0019: the request guard of an online service; returns the refusal sentence or None.
2982
- ``forwarded_proto`` is the platform-set scheme where TLS ends before the process; pass None
2983
- when the process terminates TLS itself."""
2984
- own = urllib.parse.urlsplit(origin)
2985
- own_host = own.netloc.lower()
2986
- if str(host or "").lower() != own_host:
2987
- return "Host %s is not this service." % host
2988
- if forwarded_proto is not None and str(forwarded_proto).split(",")[0].strip() != "https":
2989
- return "This service answers over https only."
2990
- if request_origin is not None and request_origin != "%s://%s" % (own.scheme, own_host):
2991
- return "Origin %s is not this service." % request_origin
2992
- if sec_fetch_site == "cross-site":
2993
- return "Cross-site requests are refused."
2994
- return None
2995
-
2996
-
2997
- def well_known_allowed(placement: str, authorization: Optional[str], token: str, scheme: str = "Bearer") -> bool:
2998
- """DEC-0019: a local service answers anyone (the loopback guard already ran); a remote one only
2999
- the bearer of the service token. False is a 401 with an EMPTY body."""
3000
- return placement != "remote" or token_matches(authorization, token, scheme)
3001
-
3002
-
3003
- def remote_token_file(service_id: str, instance: str = "default", directory: Optional[Path] = None) -> Path:
3004
- return (directory or services_dir()).parent / "tokens" / ("%s.%s.token" % (service_id, instance))
3005
-
3006
-
3007
- def register_remote(*, service_id: str, name: str, origin: str, token: str, instance: str = "default",
3008
- summary: Optional[str] = None, doctor: Optional[List[str]] = None,
3009
- directory: Optional[Path] = None, installed_by: Any = None,
3010
- runner: Optional[Dict[str, str]] = None, peer: Optional[Dict[str, Any]] = None) -> Path:
3011
- """DEC-0019, installer side: register an online service on THIS computer — the token file
3012
- (0600, never printed) and the descriptor. The same token is set on the platform as a secret.
3013
-
3014
- DEC-0061: ``installed_by`` defaults to ``{"by": "person", "role": "operator", "tool": "fabric-service
3015
- register-remote"}`` (a person runs this); pass ``role: "administrator"`` for an organization's administrator, or a
3016
- string as before. ``runner`` (``{kind?, credentialKind}``) says how its model calls sign in, as a kind and never a
3017
- credential; ``peer`` (``{endpoint, custody, residency, maxDataClass}``) offers it to others and needs ``runner``."""
3018
- if not token or len(token.strip()) < 16:
3019
- raise ServiceError("the service token must be at least 16 characters.")
3020
- root = directory or services_dir()
3021
- token_file = remote_token_file(service_id, instance, root)
3022
- descriptor: Dict[str, Any] = {"protocol": PROTOCOL, "id": service_id, "instance": instance, "name": name,
3023
- "placement": "remote", "origin": origin, "auth": {"tokenFile": str(token_file)},
3024
- "lifecycle": {"manager": "none"}, "installedAt": now_iso(),
3025
- "installedBy": installed_by if installed_by is not None else
3026
- {"by": "person", "role": "operator", "tool": "fabric-service register-remote"}}
3027
- if summary:
3028
- descriptor["summary"] = summary
3029
- if runner is not None:
3030
- descriptor["runner"] = dict(runner)
3031
- if peer is not None:
3032
- descriptor["peer"] = dict(peer)
3033
- if doctor:
3034
- descriptor["commands"] = {"doctor": list(doctor)}
3035
- problems = validate_descriptor(descriptor)
3036
- if problems:
3037
- raise ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
3038
- ensure_private_dir(token_file.parent)
3039
- atomic_write(token_file, token.strip().encode(), 0o600)
3040
- return write_descriptor(descriptor, root)
3041
-
3042
-
3043
- def cookie_value(cookie_header: Optional[str], name: str = SESSION_COOKIE) -> Optional[str]:
3044
- for part in (cookie_header or "").split(";"):
3045
- key, _, value = part.strip().partition("=")
3046
- if key == name:
3047
- return value
3048
- return None
3049
-
3050
-
3051
- # --- launchd -----------------------------------------------------------------
3052
-
3053
- # --- systemd and Task Scheduler (DEC-0032, service.md "Supervisor") ------------------
3054
-
3055
- def _no_marker_argument(argv: Sequence[str]) -> None:
3056
- """The supervisor file's writer writes the marker itself; one already in the arguments could only disagree with it."""
3057
- if _marker_arguments(list(argv)):
3058
- raise ServiceError("%s is written by the supervisor file's writer; leave it out of the program arguments" % SUPERVISOR_ARG)
3059
-
3060
-
3061
- def _no_secret_in_supervisor_file(argv: Sequence[str], environment: Optional[Dict[str, str]]) -> None:
3062
- """No supervisor file carries a secret (service.md): not in an argument, an environment name or its value."""
3063
- import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
3064
- for arg in argv:
3065
- if fabric_secrets.secret_argument(arg):
3066
- raise ServiceError("an argument carries a credential; a supervisor file never does (read it from the token file or a secret reference)")
3067
- for name, value in (environment or {}).items():
3068
- if fabric_secrets.credential_name(name) or fabric_secrets.secret_value(str(value)):
3069
- raise ServiceError("environment %s looks like a credential; a supervisor file never carries one" % name)
3070
-
3071
-
3072
- def _plain(value: str, what: str) -> str:
3073
- """A supervisor file is line- and XML-structured: no control character in anything written into it."""
3074
- if re.search(r"[\x00-\x1f\x7f]", value):
3075
- raise ServiceError("%s holds a control character (a line break, a tab, a NUL); a supervisor file cannot carry it" % what)
3076
- return value
3077
-
3078
-
3079
- def _systemd_quote(value: str) -> str:
3080
- _plain(value, "an argument")
3081
- escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%").replace("$", "$$")
3082
- return '"%s"' % escaped
3083
-
3084
-
3085
- def systemd_unit(unit: str, program_arguments: Sequence[str], *, working_directory: Path,
3086
- environment: Optional[Dict[str, str]] = None, description: Optional[str] = None,
3087
- stop_timeout: int = DEFAULT_EXIT_TIMEOUT) -> str:
3088
- """A systemd user unit (DEC-0032): Restart=always, RestartSec=10, TimeoutStopSec above the drain time,
3089
- WantedBy=default.target. The service is told who supervises it (FABRIC_SERVICE_SUPERVISOR=systemd)."""
3090
- if not _UNIT.match(unit):
3091
- raise ServiceError("unit must match %s" % _UNIT.pattern)
3092
- argv = [str(a) for a in program_arguments]
3093
- if not argv or not argv[0].startswith("/"):
3094
- raise ServiceError("a systemd unit starts an absolute executable")
3095
- if stop_timeout <= DRAIN_SECONDS:
3096
- raise ServiceError("TimeoutStopSec must outlast the %d s drain" % DRAIN_SECONDS)
3097
- env = dict(environment or {})
3098
- _no_secret_in_supervisor_file(argv, env)
3099
- _no_marker_argument(argv)
3100
- env[SUPERVISOR_ENV] = "systemd" # the unit's writer writes the marker, whatever the caller passed
3101
- # A unit is a Linux file: a Path built anywhere (a test on Windows, an installer's planner) names a POSIX path.
3102
- cwd = _plain(working_directory.as_posix() if isinstance(working_directory, PurePath) else str(working_directory),
3103
- "the working directory")
3104
- if not cwd.startswith("/"):
3105
- raise ServiceError("a systemd unit's working directory is absolute")
3106
- lines = ["[Unit]", "Description=%s" % _plain(description or unit, "the description").replace("%", "%%"), "",
3107
- "[Service]", "Type=simple", "ExecStart=%s" % " ".join(_systemd_quote(a) for a in argv),
3108
- # Not quoted: systemd reads WorkingDirectory= as the rest of the line, quotes included.
3109
- "WorkingDirectory=%s" % cwd.replace("%", "%%")]
3110
- for name in env:
3111
- if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
3112
- raise ServiceError("environment name %r is not a variable name" % name)
3113
- lines += ["Environment=%s" % _systemd_quote("%s=%s" % (k, v)) for k, v in sorted(env.items())]
3114
- lines += ["Restart=always", "RestartSec=10", "TimeoutStopSec=%d" % stop_timeout, "KillMode=mixed", "",
3115
- "[Install]", "WantedBy=default.target", ""]
3116
- return "\n".join(lines)
3117
-
3118
-
3119
- def _systemd_dir() -> Path:
3120
- return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "systemd" / "user"
3121
-
3122
-
3123
- def _check(result: Any, what: str) -> None:
3124
- if getattr(result, "returncode", 1) != 0:
3125
- raise ServiceError("%s failed: %s" % (what, (getattr(result, "stderr", "") or getattr(result, "stdout", "") or "").strip()[:200]))
3126
-
3127
-
3128
- def systemd_install(unit: str, text: str, *, unit_dir: Optional[Path] = None, run: Callable[..., Any] = subprocess.run,
3129
- system: str = sys.platform, force_enable: bool = False) -> Path:
3130
- """Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
3131
-
3132
- The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so
3133
- the new version is what starts once they enable it — unless ``force_enable`` (the operator asked for it on)."""
3134
- if not system.startswith("linux"):
3135
- raise ServiceError("systemd is the supervisor on Linux only")
3136
- if not _UNIT.match(unit):
3137
- raise ServiceError("unit must match %s" % _UNIT.pattern)
3138
- folder = unit_dir or _systemd_dir()
3139
- folder.mkdir(parents=True, exist_ok=True)
3140
- path = folder / unit
3141
- first = not path.exists()
3142
- state = "" if first else (getattr(run(["systemctl", "--user", "is-enabled", unit], capture_output=True, text=True),
3143
- "stdout", "") or "").strip()
3144
- atomic_write(path, text.encode("utf-8"), 0o644)
3145
- _check(run(["systemctl", "--user", "daemon-reload"], capture_output=True, text=True), "systemctl --user daemon-reload")
3146
- if state == "disabled" and not force_enable:
3147
- return path
3148
- _check(run(["systemctl", "--user", "enable", unit], capture_output=True, text=True), "systemctl --user enable")
3149
- _check(run(["systemctl", "--user", "restart", unit], capture_output=True, text=True), "systemctl --user restart")
3150
- return path
3151
-
3152
-
3153
- def systemd_uninstall(unit: str, *, unit_dir: Optional[Path] = None, run: Callable[..., Any] = subprocess.run,
3154
- system: str = sys.platform) -> None:
3155
- """Stop it and keep it off across logins (`disable --now`), then remove the unit."""
3156
- if not system.startswith("linux"):
3157
- raise ServiceError("systemd is the supervisor on Linux only")
3158
- run(["systemctl", "--user", "disable", "--now", unit], capture_output=True, text=True)
3159
- path = (unit_dir or _systemd_dir()) / unit
3160
- if path.exists():
3161
- path.unlink()
3162
- _check(run(["systemctl", "--user", "daemon-reload"], capture_output=True, text=True), "systemctl --user daemon-reload")
3163
-
3164
-
3165
- def _xml(text: str) -> str:
3166
- return (text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;"))
3167
-
3168
-
3169
- def task_xml(task: str, program_arguments: Sequence[str], *, working_directory: str, description: Optional[str] = None,
3170
- user: Optional[str] = None) -> str:
3171
- """A per-user Scheduled Task (DEC-0032): a logon trigger, restart on failure every minute, no execution time limit,
3172
- run only when the user is logged on, created without administrator rights."""
3173
- if not _TASK.match(task):
3174
- raise ServiceError("task must match %s" % _TASK.pattern)
3175
- argv = [str(a) for a in program_arguments]
3176
- if not argv or not re.match(r"^[A-Za-z]:[\\/]", argv[0]):
3177
- raise ServiceError("a task starts a drive-absolute executable (C:\\…)")
3178
- for value, what in [(a, "an argument") for a in argv] + [(str(working_directory), "the working directory"),
3179
- (description or task, "the description")]:
3180
- _plain(value, what)
3181
- _no_secret_in_supervisor_file(argv, None)
3182
- _no_marker_argument(argv)
3183
- # A task's Exec action carries no environment, so the marker is an argument (DEC-0057), before any `--`.
3184
- cut = argv.index("--") if "--" in argv else len(argv)
3185
- argv = argv[:cut] + [SUPERVISOR_ARG, "task-scheduler"] + argv[cut:]
3186
- who = user or ("%s\\%s" % (os.environ.get("USERDOMAIN", ""), os.environ["USERNAME"]) if os.environ.get("USERNAME") else "")
3187
- if not who:
3188
- raise ServiceError("name the user the task runs as (DOMAIN\\user); USERNAME is not set")
3189
- _plain(who, "the user")
3190
- body = """<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
3191
- <RegistrationInfo><Description>{desc}</Description><URI>{task}</URI></RegistrationInfo>
3192
- <Triggers><LogonTrigger><Enabled>true</Enabled><UserId>{user}</UserId></LogonTrigger></Triggers>
3193
- <Principals><Principal id="Author"><UserId>{user}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
3194
- <Settings>
3195
- <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
3196
- <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
3197
- <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
3198
- <StartWhenAvailable>true</StartWhenAvailable>
3199
- <Enabled>true</Enabled>
3200
- <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
3201
- <RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
3202
- </Settings>
3203
- <Actions Context="Author"><Exec><Command>{command}</Command><Arguments>{arguments}</Arguments><WorkingDirectory>{cwd}</WorkingDirectory></Exec></Actions>
3204
- </Task>
3205
- """.format(desc=_xml(description or task), task=_xml(task), user=_xml(who), command=_xml(argv[0]),
3206
- arguments=_xml(subprocess.list2cmdline(argv[1:])), cwd=_xml(str(working_directory)))
3207
- return '<?xml version="1.0" encoding="UTF-16"?>\n' + body
3208
-
3209
-
3210
- _TASK_DISABLED = re.compile(r"<Settings>(?:(?!</Settings>).)*<Enabled>false</Enabled>", re.S)
3211
-
3212
-
3213
- def task_install(task: str, xml_text: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform,
3214
- force_enable: bool = False) -> None:
3215
- """Create (or replace) the task from its XML without administrator rights, then (re)start it so the new
3216
- definition is what runs. A task already installed and disabled stays off (LC-14) unless ``force_enable``."""
3217
- if system != "win32":
3218
- raise ServiceError("Task Scheduler is the supervisor on Windows only")
3219
- query = run(["schtasks", "/Query", "/TN", task, "/XML"], capture_output=True, text=True)
3220
- existed = getattr(query, "returncode", 1) == 0
3221
- keep_off = existed and not force_enable and bool(_TASK_DISABLED.search(getattr(query, "stdout", "") or ""))
3222
- fd, name = tempfile.mkstemp(suffix=".xml")
3223
- try:
3224
- with os.fdopen(fd, "wb") as handle:
3225
- handle.write(xml_text.encode("utf-16"))
3226
- _check(run(["schtasks", "/Create", "/TN", task, "/XML", name, "/F"], capture_output=True, text=True), "schtasks /Create")
3227
- finally:
3228
- os.unlink(name)
3229
- if keep_off:
3230
- _check(run(["schtasks", "/Change", "/TN", task, "/DISABLE"], capture_output=True, text=True), "schtasks /Change /DISABLE")
3231
- return
3232
- if existed:
3233
- run(["schtasks", "/End", "/TN", task], capture_output=True, text=True)
3234
- _check(run(["schtasks", "/Run", "/TN", task], capture_output=True, text=True), "schtasks /Run")
3235
-
3236
-
3237
- def task_uninstall(task: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform) -> None:
3238
- """End the running task, then delete it."""
3239
- if system != "win32":
3240
- raise ServiceError("Task Scheduler is the supervisor on Windows only")
3241
- run(["schtasks", "/End", "/TN", task], capture_output=True, text=True)
3242
- _check(run(["schtasks", "/Delete", "/TN", task, "/F"], capture_output=True, text=True), "schtasks /Delete")
3243
-
3244
-
3245
- def launchd_plist(label: str, program_arguments: Sequence[str], *, working_directory: Path,
3246
- stdout_path: Path, environment: Optional[Dict[str, str]] = None,
3247
- exit_timeout: int = DEFAULT_EXIT_TIMEOUT) -> bytes:
3248
- """RunAtLoad + KeepAlive true + ThrottleInterval 10; no secret may appear in `environment`.
3249
-
3250
- ``ExitTimeOut`` (15 s) sits above the drain and its hard exit (``Drain``: 8 + 2 s), so launchd's
3251
- SIGKILL never arrives first. ``FABRIC_SERVICE_SUPERVISOR=launchd`` tells the lock to back off
3252
- instead of exiting into a respawn loop."""
3253
- env = dict(environment or {})
3254
- for key in env:
3255
- if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)$", key) and not key.endswith("_FILE"):
3256
- raise ServiceError("Environment variable %s looks like a secret; pass a *_FILE path instead." % key)
3257
- env.setdefault("PATH", "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin")
3258
- _no_marker_argument([str(a) for a in program_arguments])
3259
- env[SUPERVISOR_ENV] = "launchd"
3260
- plist = {
3261
- "Label": label,
3262
- "ProgramArguments": list(program_arguments),
3263
- "WorkingDirectory": str(working_directory),
3264
- "RunAtLoad": True,
3265
- "KeepAlive": True,
3266
- "ThrottleInterval": 10,
3267
- "ExitTimeOut": exit_timeout,
3268
- # Standard, never Background: a host probes the service and agents call it while the Mac
3269
- # is busy; Background (and Nice/LowPriorityIO) lets macOS starve it for tens of seconds
3270
- # under load, and every host then reports an outage that never happened.
3271
- "ProcessType": "Standard",
3272
- "StandardOutPath": str(stdout_path),
3273
- "StandardErrorPath": str(stdout_path),
3274
- "EnvironmentVariables": env,
3275
- }
3276
- return plistlib.dumps(plist)
3277
-
3278
-
3279
- def _launchctl(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
3280
- return subprocess.run(["launchctl", *args], capture_output=True, text=True, timeout=timeout)
3281
-
3282
-
3283
- def _domain() -> str:
3284
- if sys.platform == "win32":
3285
- # DEC-0032: launchd is macOS's supervisor; Windows has Task Scheduler, and no uid.
3286
- raise ServiceError("launchd exists only on macOS; this system's supervisor is Task Scheduler.")
3287
- return "gui/%d" % os.getuid()
3288
-
3289
-
3290
- def launchd_loaded(label: str) -> bool:
3291
- return _launchctl("print", "%s/%s" % (_domain(), label)).returncode == 0
3292
-
3293
-
3294
- def fetch_well_known(origin: str, timeout: float = 2.0) -> Optional[Dict[str, Any]]:
3295
- try:
3296
- with urllib.request.urlopen(origin + "/.well-known/fabric-service", timeout=timeout) as response:
3297
- return json.loads(response.read().decode())
3298
- except (urllib.error.URLError, OSError, ValueError):
3299
- return None
3300
-
3301
-
3302
- _OVERRIDE_ROW = re.compile(r'^\s*"([^"]+)"\s*=>\s*(\S+)\s*$')
3303
-
3304
-
3305
- def launchd_override(label: str) -> Optional[str]:
3306
- """The operator's launchd override for ``label``: ``"disabled"``, ``"enabled"``, None when
3307
- none is recorded (a first install), or ``"unknown"`` when the table cannot be read.
3308
-
3309
- Reads ``launchctl print-disabled gui/<uid>``; macOS 14+ prints ``=> disabled|enabled``,
3310
- older releases ``=> true|false`` (true means disabled)."""
3311
- result = _launchctl("print-disabled", _domain())
3312
- if result.returncode != 0:
3313
- return "unknown"
3314
- for line in result.stdout.splitlines():
3315
- match = _OVERRIDE_ROW.match(line)
3316
- if match and match.group(1) == label:
3317
- return "disabled" if match.group(2) in ("disabled", "true") else "enabled"
3318
- return None
3319
-
3320
-
3321
- def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin: str,
3322
- service_id: str, instance: str = "default", timeout: float = 40.0,
3323
- force_enable: bool = False) -> Dict[str, Any]:
3324
- """Write + lint the plist, bootout and WAIT for unload, bootstrap (retry EIO 5), then
3325
- poll the well-known document until it answers with this identity.
3326
-
3327
- The operator's intent wins (LC-14): only a first install (no override recorded) enables the
3328
- label. A label the operator disabled stays disabled and unloaded — the plist is still
3329
- rewritten, so the new release is what starts once they turn it back on — and the answer is
3330
- ``{"disabled": True, "label", "plist", "loaded"}`` instead of the well-known document.
3331
- ``force_enable`` is for an operator who asked for the service to be switched on."""
3332
- plist_path = Path(plist_path)
3333
- atomic_write(plist_path, plist_bytes, 0o644)
3334
- lint = subprocess.run(["plutil", "-lint", str(plist_path)], capture_output=True, text=True)
3335
- if lint.returncode != 0:
3336
- raise ServiceError("plist failed plutil -lint: %s" % lint.stdout.strip())
3337
- target = "%s/%s" % (_domain(), label)
3338
- override = launchd_override(label)
3339
- if force_enable or override is None:
3340
- _launchctl("enable", target)
3341
- elif override == "disabled":
3342
- return {"disabled": True, "label": label, "plist": str(plist_path), "loaded": launchd_loaded(label)}
3343
- _launchctl("bootout", target)
3344
- deadline = time.time() + 15
3345
- while launchd_loaded(label) and time.time() < deadline:
3346
- time.sleep(0.25)
3347
- last = ""
3348
- for _ in range(5):
3349
- result = _launchctl("bootstrap", _domain(), str(plist_path))
3350
- if result.returncode == 0:
3351
- break
3352
- last = (result.stderr or result.stdout).strip()
3353
- time.sleep(1.0)
3354
- else:
3355
- raise ServiceError("launchctl bootstrap failed: %s" % last)
3356
- deadline = time.time() + timeout
3357
- while time.time() < deadline:
3358
- doc = fetch_well_known(origin)
3359
- if doc and doc.get("service", {}).get("id") == service_id and doc.get("service", {}).get("instance") == instance:
3360
- return doc
3361
- time.sleep(0.5)
3362
- raise ServiceError("%s did not answer as %s.%s within %.0f s; read its log." % (origin, service_id, instance, timeout))
3363
-
3364
-
3365
- def launchd_uninstall(label: str, plist_path: Path, *, timeout: float = 30.0, purge: bool = False,
3366
- service_id: Optional[str] = None) -> Dict[str, Any]:
3367
- """Symmetric with ``launchd_install`` (LC-14): bootout and WAIT until the job is gone, remove
3368
- the plist, and reset the override to ``enabled`` — launchctl has no verb that deletes an
3369
- override, and a leftover ``disabled`` would make a later fresh install stay off. Data stays
3370
- unless ``purge`` (then ``service_id`` names whose data, logs and cache go). A job still
3371
- loaded at ``timeout`` raises, leaving the plist and data in place."""
3372
- if purge and not service_id:
3373
- raise ServiceError("purge needs the service id whose data, logs and cache to remove.")
3374
- target = "%s/%s" % (_domain(), label)
3375
- _launchctl("bootout", target)
3376
- deadline = time.monotonic() + timeout
3377
- while launchd_loaded(label):
3378
- if time.monotonic() >= deadline:
3379
- raise ServiceError("launchd job %s is still loaded %.0f s after bootout; nothing was removed." % (label, timeout))
3380
- time.sleep(0.1)
3381
- try:
3382
- Path(plist_path).unlink()
3383
- plist_removed = True
3384
- except FileNotFoundError:
3385
- plist_removed = False
3386
- _launchctl("enable", target)
3387
- purged: List[str] = []
3388
- if purge:
3389
- for directory in service_dirs(str(service_id)).values():
3390
- if directory.is_symlink():
3391
- directory.unlink()
3392
- elif directory.exists():
3393
- shutil.rmtree(directory)
3394
- else:
3395
- continue
3396
- purged.append(str(directory))
3397
- return {"unloaded": True, "plistRemoved": plist_removed, "override": "enabled", "purged": purged}
3398
-
3399
-
3400
- def prune_releases(releases_dir: Path, keep: int = 2, *, current: Optional[Path] = None) -> List[Path]:
3401
- """Keep the release the job runs and the one before it (LC-11 rollback, LC-15); remove
3402
- older release directories, newest first by modification time. ``current`` (a release or a
3403
- symlink to one) is never removed, even after a rollback to an older release. Symlinks and
3404
- files in ``releases_dir`` are left alone. Returns what was removed."""
3405
- if keep < 2:
3406
- raise ServiceError("keep at least the current release and the one before it (keep >= 2).")
3407
- root = Path(releases_dir)
3408
- if not root.is_dir():
3409
- return []
3410
- releases = [p for p in root.iterdir() if p.is_dir() and not p.is_symlink()]
3411
- releases.sort(key=lambda p: p.stat().st_mtime, reverse=True)
3412
- keepers: List[Path] = []
3413
- if current is not None:
3414
- resolved = Path(current).resolve()
3415
- keepers.extend(p for p in releases if p.resolve() == resolved)
3416
- for release in releases:
3417
- if len(keepers) >= keep:
3418
- break
3419
- if release not in keepers:
3420
- keepers.append(release)
3421
- removed = [p for p in releases if p not in keepers]
3422
- for release in removed:
3423
- shutil.rmtree(release)
3424
- return removed
3425
-
3426
-
3427
- # --- helpers for input validation -----------------------------------------------
3428
-
3429
- def _require_id(service_id: str) -> None:
3430
- if not (isinstance(service_id, str) and _ID.fullmatch(service_id)):
3431
- raise ServiceError("Service id %r must match %s." % (service_id, _ID.pattern))
3432
-
3433
-
3434
- __all__ = [name for name in dir() if not name.startswith("_")]
11
+ import os # noqa: F401 - callers reach these through the facade
12
+ import sys # noqa: F401 - callers reach these through the facade
13
+ from pathlib import Path # noqa: F401
14
+
15
+ import sys as _sys # noqa: E402
16
+ from pathlib import Path as _Path # noqa: E402
17
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
18
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
19
+ # Private names 0.39.1 code reached through this module; they live in the core now (FAA-65).
20
+ from fabric_jsvalue import is_number as _number # noqa: E402,F401
21
+ from fabric_time import DAY_MS as _DAY_MS, utc_date_ms as _utc_date_ms # noqa: E402,F401
22
+ from fabric_base import ( # noqa: F401
23
+ PROTOCOL,
24
+ EXTENSION_KEY,
25
+ SUPERVISOR_ENV,
26
+ DRAIN_SECONDS,
27
+ _ID,
28
+ _INSTANCE,
29
+ _LOCAL_PATH,
30
+ _UNIT,
31
+ _TASK,
32
+ WINDOWS,
33
+ _O_BINARY,
34
+ ServiceError,
35
+ now_iso,
36
+ expand,
37
+ _require_id,
38
+ )
39
+ from fabric_platform import ( # noqa: F401
40
+ WINDOWS_TRUSTED,
41
+ token_file_problem,
42
+ SYSTEM_SID,
43
+ ADMINISTRATORS_SID,
44
+ _SID_TEXT,
45
+ _sid,
46
+ windows_token_acl_problem,
47
+ _SDDL_ACE_TYPES,
48
+ _SDDL_RIGHTS,
49
+ _sddl_ace_strings,
50
+ _sddl_mask,
51
+ acl_from_sddl,
52
+ _win_protect,
53
+ _create_first_pipe,
54
+ _close_handle,
55
+ _pipe_exists,
56
+ _win_token_problem,
57
+ )
58
+ import fabric_platform as _fabric_platform # noqa: E402
59
+ for _name in ('ctypes', 'wintypes', '_advapi32', '_kernel32', '_PVOID', '_REPARSE_POINT', '_win_error', '_sid_text', '_canonical_sid', 'windows_user_sid', '_sddl', 'windows_acl', '_INVALID_HANDLE', '_CANONICAL'): # bound on Windows only, as before the split
60
+ if hasattr(_fabric_platform, _name):
61
+ globals()[_name] = getattr(_fabric_platform, _name)
62
+
63
+ from fabric_paths import ( # noqa: F401
64
+ services_dir,
65
+ service_dirs,
66
+ _read_at,
67
+ _local_app_data,
68
+ ensure_private_dir,
69
+ atomic_write,
70
+ )
71
+ from fabric_protocols import ( # noqa: F401
72
+ PROTOCOL_REVISIONS,
73
+ MAX_PROTOCOLS,
74
+ _REVISION,
75
+ parse_revision,
76
+ accepted_revisions,
77
+ negotiate_revision,
78
+ protocols_findings,
79
+ _SCHEMA_URI,
80
+ _scalar,
81
+ feed_data_problems,
82
+ _pointer_token,
83
+ feed_data_findings,
84
+ )
85
+ from fabric_lock import ( # noqa: F401
86
+ EXIT_ALREADY_RUNNING,
87
+ LOCK_WAIT_SUPERVISED_SECONDS,
88
+ LOCK_BACKOFF_FIRST_SECONDS,
89
+ LOCK_BACKOFF_MAX_SECONDS,
90
+ AlreadyRunning,
91
+ InstanceLock,
92
+ lock_name,
93
+ _hold_lock_name,
94
+ _drop_lock_name,
95
+ lock_name_held,
96
+ _read_pid,
97
+ SUPERVISORS,
98
+ SUPERVISOR_ARG,
99
+ _marker_arguments,
100
+ supervisor_marker,
101
+ lock_wait_budget_seconds,
102
+ marker_conflict_message,
103
+ supervised,
104
+ supervised_by_launchd,
105
+ add_supervisor_argument,
106
+ hold_single_instance,
107
+ )
108
+ from fabric_usage import ( # noqa: F401
109
+ ROUTES,
110
+ USAGE_ROUTES,
111
+ USAGE_PATH,
112
+ USAGE_DAYS,
113
+ COST_BASES,
114
+ _PROVIDER,
115
+ make_usage_receipt,
116
+ _TASK_RUN_ID,
117
+ _by_project,
118
+ _add_totals,
119
+ _close_totals,
120
+ _USAGE_WINDOW_DAYS,
121
+ _usage_count,
122
+ _usage_money,
123
+ _usage_cost,
124
+ _exceeds,
125
+ _usd,
126
+ _usage_list,
127
+ _priced_row,
128
+ _MISSING_COST,
129
+ usage_findings,
130
+ OPENROUTER_CATEGORIES,
131
+ _ATTR_PRODUCT,
132
+ _ATTR_TITLE,
133
+ _ATTRIBUTION_HEADERS,
134
+ attribution_setting_findings,
135
+ _is_openrouter,
136
+ attribution_headers,
137
+ usage_report,
138
+ _spent_since,
139
+ _spent_in,
140
+ LIMIT_SCOPES,
141
+ PER_ORDER_KINDS,
142
+ _LIMIT_ID,
143
+ _LIMIT_KIND,
144
+ _LIMIT_FIELDS,
145
+ _TIMESTAMP,
146
+ _is_timestamp,
147
+ usage_limit_problems,
148
+ JsonlUsageLedger,
149
+ )
150
+ from fabric_backups import ( # noqa: F401
151
+ SETTINGS_BACKUP_FORMAT,
152
+ SETTINGS_BACKUP_KEEP,
153
+ SETTINGS_BACKUP_MAX_AGE_SECONDS,
154
+ _STALE_TEMP_SECONDS,
155
+ _SAFE_INTEGER,
156
+ _TABLE,
157
+ _SECRET_TABLE,
158
+ _COLUMN,
159
+ _SECRET_COLUMN,
160
+ _REASON,
161
+ _SHA256,
162
+ _SURROGATE,
163
+ _URI,
164
+ _MAX_CELL_CHARS,
165
+ _MAX_ROW_VALUES,
166
+ _canonical_check,
167
+ settings_canonical_json,
168
+ settings_backup_digest,
169
+ settings_backup_problems,
170
+ settings_backup_root,
171
+ settings_backup_dir,
172
+ make_settings_backup,
173
+ _backup_name,
174
+ settings_backups,
175
+ _no_constant,
176
+ read_settings_backup,
177
+ write_settings_backup,
178
+ backup_due,
179
+ newest_intact_backup,
180
+ restore_plan,
181
+ )
182
+ from fabric_descriptor import ( # noqa: F401
183
+ DASHBOARD_EXTENSION_KEY,
184
+ DASHBOARD_KIT,
185
+ REFERENCE_THEMES,
186
+ STATUSES,
187
+ _ORIGIN,
188
+ _REMOTE_ORIGIN,
189
+ _RESERVED_HOST,
190
+ PLACEMENTS,
191
+ MANAGERS,
192
+ _NOT_LAUNCHD,
193
+ _AUTH_HEADER,
194
+ LoopbackHTTPServer,
195
+ descriptor_path,
196
+ read_descriptors,
197
+ validate_descriptor,
198
+ PEER_DATA_CLASSES,
199
+ CREDENTIAL_KINDS,
200
+ SUBSCRIPTION_RUNNERS,
201
+ _PEER_ENDPOINT,
202
+ _RESIDENCY,
203
+ _RUNNER_KIND_NAME,
204
+ _pointer,
205
+ _text,
206
+ descriptor_member_problems,
207
+ peer_refusal,
208
+ app_descriptor_action,
209
+ _MISSING_APP,
210
+ app_descriptor_stale,
211
+ app_present,
212
+ FABRIC_SERVICE,
213
+ fabric_button_presence,
214
+ placement_of,
215
+ remote_origin_problems,
216
+ port_of,
217
+ write_descriptor,
218
+ remove_descriptor,
219
+ build_info,
220
+ _THEME_ID,
221
+ _KIT_REF,
222
+ dashboard_extension_problems,
223
+ dashboard_extension,
224
+ _PROFILE_ID,
225
+ _RUNNER_KIND,
226
+ _MODEL_ID,
227
+ configuration_problems,
228
+ build_well_known,
229
+ )
230
+ from fabric_auth import ( # noqa: F401
231
+ LOGIN_CODE_TTL_SECONDS,
232
+ OPERATOR_SESSION_IDLE_SECONDS,
233
+ OPERATOR_SESSION_MAX_SECONDS,
234
+ _CODE,
235
+ ensure_token,
236
+ read_token,
237
+ token_matches,
238
+ check_request,
239
+ token_header,
240
+ LoginCodes,
241
+ _session_bound,
242
+ MemoryCodeStore,
243
+ SESSION_COOKIE,
244
+ REMOTE_SESSION_COOKIE,
245
+ session_cookie_header,
246
+ remote_session_cookie_header,
247
+ expired_session_cookie_header,
248
+ check_remote_request,
249
+ well_known_allowed,
250
+ remote_token_file,
251
+ register_remote,
252
+ cookie_value,
253
+ )
254
+ from fabric_events import ( # noqa: F401
255
+ EVENTS_DEFAULT_LIMIT,
256
+ EVENTS_MAX_LIMIT,
257
+ LEVELS,
258
+ _KIND,
259
+ _TRACE_ID,
260
+ _SPAN_ID,
261
+ make_event,
262
+ parse_limit,
263
+ events_page,
264
+ JsonlEventLog,
265
+ )
266
+ from fabric_logs import ( # noqa: F401
267
+ DRAIN_GRACE_SECONDS,
268
+ EXIT_HARD_STOP,
269
+ LOG_MAX_BYTES,
270
+ LOG_BACKUPS,
271
+ RotatingLog,
272
+ cap_stdout_log,
273
+ Stopping,
274
+ Drain,
275
+ )
276
+ from fabric_supervisors import ( # noqa: F401
277
+ DEFAULT_EXIT_TIMEOUT,
278
+ _no_marker_argument,
279
+ _no_secret_in_supervisor_file,
280
+ _plain,
281
+ _systemd_quote,
282
+ systemd_unit,
283
+ _systemd_dir,
284
+ _check,
285
+ systemd_install,
286
+ systemd_uninstall,
287
+ _xml,
288
+ task_xml,
289
+ _TASK_DISABLED,
290
+ task_install,
291
+ task_uninstall,
292
+ launchd_plist,
293
+ _launchctl,
294
+ _domain,
295
+ launchd_loaded,
296
+ fetch_well_known,
297
+ _OVERRIDE_ROW,
298
+ launchd_override,
299
+ launchd_install,
300
+ launchd_uninstall,
301
+ prune_releases,
302
+ )