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