@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
@@ -50,9 +50,9 @@ def _objects(value: Any) -> List[Any]:
50
50
  return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
51
51
 
52
52
 
53
- def _indexed(value: Any) -> List[Any]:
54
- """The object items of a list with their index as written, so an instancePath names the item after a non-object."""
55
- return [(item, index) for index, item in enumerate(value) if isinstance(item, dict)] if isinstance(value, list) else []
53
+ from fabric_jsvalue import indexed_objects as _indexed # noqa: E402 - the core's (FAA-65)
54
+ import fabric_time as _ftime # noqa: E402 - the core (FAA-65)
55
+ import fabric_jsvalue as _jsv # noqa: E402 - the core (FAA-65)
56
56
 
57
57
 
58
58
  def is_share_grant_id(value: Any) -> bool:
@@ -91,8 +91,8 @@ def grant_findings(grant: Any, at: str = "") -> List[Dict[str, str]]:
91
91
  def _agents_under(record: Dict[str, Any], context: Any, grant_id: Any) -> set:
92
92
  """The agentRefs the placements of ``record`` in ``context`` run under the grant ``grant_id`` (by normal form)."""
93
93
  placements = [placement for machine in _objects(record.get("machines")) for placement in _objects(machine.get("packages"))]
94
- return {_estate._show(placement.get("agentRef", _estate._MISSING)) for placement in placements
95
- if _estate._same(placement.get("context", _estate._MISSING), context)
94
+ return {_jsv.show(placement.get("agentRef", _jsv.MISSING)) for placement in placements
95
+ if _jsv.same(placement.get("context", _jsv.MISSING), context)
96
96
  and isinstance(grant_id, str) and _ids.same_identifier(placement.get("grant"), grant_id)}
97
97
 
98
98
 
@@ -116,7 +116,7 @@ def org_share_grants_findings(value: Any) -> List[Dict[str, str]]:
116
116
  if parent is None:
117
117
  continue
118
118
  before = next((old for old in _objects(parent.get("shareGrants"))
119
- if _estate._same(old.get("context", _estate._MISSING), grant["context"]) and isinstance(old.get("meta"), dict)
119
+ if _jsv.same(old.get("context", _jsv.MISSING), grant["context"]) and isinstance(old.get("meta"), dict)
120
120
  and isinstance(grant_id, str) and _ids.same_identifier(old["meta"].get("id"), grant_id)), None)
121
121
  earlier = _agents_under(parent, before.get("context"), grant_id) if before is not None else set()
122
122
  if earlier and any(ref not in earlier for ref in refs):
@@ -126,7 +126,7 @@ def org_share_grants_findings(value: Any) -> List[Dict[str, str]]:
126
126
 
127
127
  def _time(value: Any) -> float:
128
128
  """An instant as milliseconds, or NaN: the schema's pattern and a day that exists."""
129
- return _estate.estate_time(value) if isinstance(value, str) and _INSTANT.match(value) else float("nan")
129
+ return _ftime.parse_rfc3339_ms(value) if isinstance(value, str) and _INSTANT.match(value) else float("nan")
130
130
 
131
131
 
132
132
  def registration_findings(value: Any) -> List[Dict[str, str]]:
@@ -152,7 +152,7 @@ def grant_usage_context_findings(value: Any) -> List[Dict[str, str]]:
152
152
  for position, event in enumerate(_objects(batch.get("events"))): # the index among the objects, as the contract counts it
153
153
  if event.get("kind") != "grant.usage":
154
154
  continue
155
- context = event["data"].get("context", _estate._MISSING) if isinstance(event.get("data"), dict) else _estate._MISSING
155
+ context = event["data"].get("context", _jsv.MISSING) if isinstance(event.get("data"), dict) else _jsv.MISSING
156
156
  if not isinstance(org, str) or not isinstance(context, str) or context != "org:" + org:
157
157
  findings.append(_finding("FAC-SEM-045", "/batch/events/%d/data/context" % position, "a grant's usage carries its grant's own context, org: and the client certificate's org.id; another organization's is refused as attribution"))
158
158
  return findings
@@ -0,0 +1,437 @@
1
+ """Supervisors (DEC-0032, LC-14): launchd plists, systemd user units and Task Scheduler tasks, installed and
2
+ removed without touching what the operator turned off; release pruning (LC-15). Part of the service kit
3
+ (FAA-65, layer L3)."""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import plistlib
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ import urllib.error
16
+ import urllib.request
17
+ from pathlib import Path, PurePath
18
+ from typing import Any, Callable, Dict, List, Optional, Sequence
19
+
20
+ import sys as _sys # noqa: E402
21
+ from pathlib import Path as _Path # noqa: E402
22
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
23
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
24
+ from fabric_base import (
25
+ DRAIN_SECONDS,
26
+ SUPERVISOR_ENV,
27
+ ServiceError,
28
+ _TASK,
29
+ _UNIT,
30
+ )
31
+ from fabric_paths import (
32
+ atomic_write,
33
+ service_dirs,
34
+ )
35
+ from fabric_lock import (
36
+ SUPERVISOR_ARG,
37
+ _marker_arguments,
38
+ )
39
+
40
+
41
+ DEFAULT_EXIT_TIMEOUT = 15
42
+
43
+
44
+ _LABEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,250}\Z") # what launchd and the Node twin take as a label
45
+
46
+
47
+ def _no_marker_argument(argv: Sequence[str]) -> None:
48
+ """The supervisor file's writer writes the marker itself; one already in the arguments could only disagree with it."""
49
+ if _marker_arguments(list(argv)):
50
+ raise ServiceError("%s is written by the supervisor file's writer; leave it out of the program arguments" % SUPERVISOR_ARG)
51
+
52
+
53
+ def _no_secret_in_supervisor_file(argv: Sequence[str], environment: Optional[Dict[str, str]]) -> None:
54
+ """No supervisor file carries a secret (service.md): not in an argument, an environment name or its value."""
55
+ import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
56
+ for arg in argv:
57
+ if fabric_secrets.secret_argument(arg):
58
+ raise ServiceError("an argument carries a credential; a supervisor file never does (read it from the token file or a secret reference)")
59
+ for name, value in (environment or {}).items():
60
+ if fabric_secrets.credential_name(name) or fabric_secrets.secret_value(str(value)):
61
+ raise ServiceError("environment %s looks like a credential; a supervisor file never carries one" % name)
62
+
63
+
64
+ def _plain(value: str, what: str) -> str:
65
+ """A supervisor file is line- and XML-structured: no control character in anything written into it."""
66
+ if re.search(r"[\x00-\x1f\x7f]", value):
67
+ raise ServiceError("%s holds a control character (a line break, a tab, a NUL); a supervisor file cannot carry it" % what)
68
+ return value
69
+
70
+
71
+ def _systemd_quote(value: str, dollar: bool = True) -> str:
72
+ """A quoted systemd value. ExecStart= expands $VAR, so its $ is doubled; Environment= expands none, so `dollar`
73
+ False leaves it alone (finding 17). Both expand % specifiers."""
74
+ _plain(value, "an argument")
75
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%")
76
+ return '"%s"' % (escaped.replace("$", "$$") if dollar else escaped)
77
+
78
+
79
+ def systemd_unit(unit: str, program_arguments: Sequence[str], *, working_directory: Path,
80
+ environment: Optional[Dict[str, str]] = None, description: Optional[str] = None,
81
+ stop_timeout: int = DEFAULT_EXIT_TIMEOUT) -> str:
82
+ """A systemd user unit (DEC-0032): Restart=always, RestartSec=10, TimeoutStopSec above the drain time,
83
+ WantedBy=default.target. The service is told who supervises it (FABRIC_SERVICE_SUPERVISOR=systemd)."""
84
+ if not _UNIT.match(unit):
85
+ raise ServiceError("unit must match %s" % _UNIT.pattern)
86
+ argv = [str(a) for a in program_arguments]
87
+ if not argv or not argv[0].startswith("/"):
88
+ raise ServiceError("a systemd unit starts an absolute executable")
89
+ if stop_timeout <= DRAIN_SECONDS:
90
+ raise ServiceError("TimeoutStopSec must outlast the %d s drain" % DRAIN_SECONDS)
91
+ env = dict(environment or {})
92
+ _no_secret_in_supervisor_file(argv, env)
93
+ _no_marker_argument(argv)
94
+ env[SUPERVISOR_ENV] = "systemd" # the unit's writer writes the marker, whatever the caller passed
95
+ # A unit is a Linux file: a Path built anywhere (a test on Windows, an installer's planner) names a POSIX path.
96
+ cwd = _plain(working_directory.as_posix() if isinstance(working_directory, PurePath) else str(working_directory),
97
+ "the working directory")
98
+ if not cwd.startswith("/"):
99
+ raise ServiceError("a systemd unit's working directory is absolute")
100
+ lines = ["[Unit]", "Description=%s" % _plain(description or unit, "the description").replace("%", "%%"), "",
101
+ "[Service]", "Type=simple", "ExecStart=%s" % " ".join(_systemd_quote(a) for a in argv),
102
+ # Not quoted: systemd reads WorkingDirectory= as the rest of the line, quotes included.
103
+ "WorkingDirectory=%s" % cwd.replace("%", "%%")]
104
+ for name in env:
105
+ if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*\Z", name):
106
+ raise ServiceError("environment name %r is not a variable name" % name)
107
+ lines += ["Environment=%s" % _systemd_quote("%s=%s" % (k, v), dollar=False) for k, v in sorted(env.items())]
108
+ lines += ["Restart=always", "RestartSec=10", "TimeoutStopSec=%d" % stop_timeout, "KillMode=mixed", "",
109
+ "[Install]", "WantedBy=default.target", ""]
110
+ return "\n".join(lines)
111
+
112
+
113
+ def _systemd_dir() -> Path:
114
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "systemd" / "user"
115
+
116
+
117
+ def _check(result: Any, what: str) -> None:
118
+ if getattr(result, "returncode", 1) != 0:
119
+ raise ServiceError("%s failed: %s" % (what, (getattr(result, "stderr", "") or getattr(result, "stdout", "") or "").strip()[:200]))
120
+
121
+
122
+ def systemd_install(unit: str, text: str, *, unit_dir: Optional[Path] = None, run: Callable[..., Any] = subprocess.run,
123
+ system: str = sys.platform, force_enable: bool = False) -> Path:
124
+ """Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
125
+
126
+ The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so
127
+ the new version is what starts once they enable it — unless ``force_enable`` (the operator asked for it on)."""
128
+ if not system.startswith("linux"):
129
+ raise ServiceError("systemd is the supervisor on Linux only")
130
+ if not _UNIT.match(unit):
131
+ raise ServiceError("unit must match %s" % _UNIT.pattern)
132
+ folder = unit_dir or _systemd_dir()
133
+ folder.mkdir(parents=True, exist_ok=True)
134
+ path = folder / unit
135
+ first = not path.exists()
136
+ state = "" if first else (getattr(run(["systemctl", "--user", "is-enabled", unit], capture_output=True, text=True),
137
+ "stdout", "") or "").strip()
138
+ atomic_write(path, text.encode("utf-8"), 0o644)
139
+ _check(run(["systemctl", "--user", "daemon-reload"], capture_output=True, text=True), "systemctl --user daemon-reload")
140
+ if state == "disabled" and not force_enable:
141
+ return path
142
+ _check(run(["systemctl", "--user", "enable", unit], capture_output=True, text=True), "systemctl --user enable")
143
+ _check(run(["systemctl", "--user", "restart", unit], capture_output=True, text=True), "systemctl --user restart")
144
+ return path
145
+
146
+
147
+ def systemd_uninstall(unit: str, *, unit_dir: Optional[Path] = None, run: Callable[..., Any] = subprocess.run,
148
+ system: str = sys.platform) -> None:
149
+ """Stop it and keep it off across logins (`disable --now`), then remove the unit."""
150
+ if not system.startswith("linux"):
151
+ raise ServiceError("systemd is the supervisor on Linux only")
152
+ run(["systemctl", "--user", "disable", "--now", unit], capture_output=True, text=True)
153
+ path = (unit_dir or _systemd_dir()) / unit
154
+ if path.exists():
155
+ path.unlink()
156
+ _check(run(["systemctl", "--user", "daemon-reload"], capture_output=True, text=True), "systemctl --user daemon-reload")
157
+
158
+
159
+ def _xml(text: str) -> str:
160
+ return (text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;"))
161
+
162
+
163
+ def task_xml(task: str, program_arguments: Sequence[str], *, working_directory: str, description: Optional[str] = None,
164
+ user: Optional[str] = None) -> str:
165
+ """A per-user Scheduled Task (DEC-0032): a logon trigger, restart on failure every minute, no execution time limit,
166
+ run only when the user is logged on, created without administrator rights."""
167
+ if not _TASK.match(task):
168
+ raise ServiceError("task must match %s" % _TASK.pattern)
169
+ argv = [str(a) for a in program_arguments]
170
+ if not argv or not re.match(r"^[A-Za-z]:[\\/]", argv[0]):
171
+ raise ServiceError("a task starts a drive-absolute executable (C:\\…)")
172
+ for value, what in [(a, "an argument") for a in argv] + [(str(working_directory), "the working directory"),
173
+ (description or task, "the description")]:
174
+ _plain(value, what)
175
+ _no_secret_in_supervisor_file(argv, None)
176
+ _no_marker_argument(argv)
177
+ # A task's Exec action carries no environment, so the marker is an argument (DEC-0057), before any `--`.
178
+ cut = argv.index("--") if "--" in argv else len(argv)
179
+ argv = argv[:cut] + [SUPERVISOR_ARG, "task-scheduler"] + argv[cut:]
180
+ who = user or ("%s\\%s" % (os.environ.get("USERDOMAIN", ""), os.environ["USERNAME"]) if os.environ.get("USERNAME") else "")
181
+ if not who:
182
+ raise ServiceError("name the user the task runs as (DOMAIN\\user); USERNAME is not set")
183
+ _plain(who, "the user")
184
+ body = """<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
185
+ <RegistrationInfo><Description>{desc}</Description><URI>{task}</URI></RegistrationInfo>
186
+ <Triggers><LogonTrigger><Enabled>true</Enabled><UserId>{user}</UserId></LogonTrigger></Triggers>
187
+ <Principals><Principal id="Author"><UserId>{user}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
188
+ <Settings>
189
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
190
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
191
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
192
+ <StartWhenAvailable>true</StartWhenAvailable>
193
+ <Enabled>true</Enabled>
194
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
195
+ <RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
196
+ </Settings>
197
+ <Actions Context="Author"><Exec><Command>{command}</Command><Arguments>{arguments}</Arguments><WorkingDirectory>{cwd}</WorkingDirectory></Exec></Actions>
198
+ </Task>
199
+ """.format(desc=_xml(description or task), task=_xml(task), user=_xml(who), command=_xml(argv[0]),
200
+ arguments=_xml(subprocess.list2cmdline(argv[1:])), cwd=_xml(str(working_directory)))
201
+ return '<?xml version="1.0" encoding="UTF-16"?>\n' + body
202
+
203
+
204
+ _TASK_DISABLED = re.compile(r"<Settings>(?:(?!</Settings>).)*<Enabled>false</Enabled>", re.S)
205
+
206
+
207
+ def task_install(task: str, xml_text: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform,
208
+ force_enable: bool = False) -> None:
209
+ """Create (or replace) the task from its XML without administrator rights, then (re)start it so the new
210
+ definition is what runs. A task already installed and disabled stays off (LC-14) unless ``force_enable``."""
211
+ if system != "win32":
212
+ raise ServiceError("Task Scheduler is the supervisor on Windows only")
213
+ query = run(["schtasks", "/Query", "/TN", task, "/XML"], capture_output=True, text=True)
214
+ existed = getattr(query, "returncode", 1) == 0
215
+ keep_off = existed and not force_enable and bool(_TASK_DISABLED.search(getattr(query, "stdout", "") or ""))
216
+ fd, name = tempfile.mkstemp(suffix=".xml")
217
+ try:
218
+ with os.fdopen(fd, "wb") as handle:
219
+ handle.write(xml_text.encode("utf-16"))
220
+ _check(run(["schtasks", "/Create", "/TN", task, "/XML", name, "/F"], capture_output=True, text=True), "schtasks /Create")
221
+ finally:
222
+ os.unlink(name)
223
+ if keep_off:
224
+ _check(run(["schtasks", "/Change", "/TN", task, "/DISABLE"], capture_output=True, text=True), "schtasks /Change /DISABLE")
225
+ return
226
+ if existed:
227
+ run(["schtasks", "/End", "/TN", task], capture_output=True, text=True)
228
+ _check(run(["schtasks", "/Run", "/TN", task], capture_output=True, text=True), "schtasks /Run")
229
+
230
+
231
+ def task_uninstall(task: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform) -> None:
232
+ """End the running task, then delete it."""
233
+ if system != "win32":
234
+ raise ServiceError("Task Scheduler is the supervisor on Windows only")
235
+ run(["schtasks", "/End", "/TN", task], capture_output=True, text=True)
236
+ _check(run(["schtasks", "/Delete", "/TN", task, "/F"], capture_output=True, text=True), "schtasks /Delete")
237
+
238
+
239
+ def launchd_plist(label: str, program_arguments: Sequence[str], *, working_directory: Path,
240
+ stdout_path: Path, environment: Optional[Dict[str, str]] = None,
241
+ exit_timeout: int = DEFAULT_EXIT_TIMEOUT) -> bytes:
242
+ """RunAtLoad + KeepAlive true + ThrottleInterval 10; no secret may appear in `environment`.
243
+
244
+ ``ExitTimeOut`` (15 s) sits above the drain and its hard exit (``Drain``: 8 + 2 s), so launchd's
245
+ SIGKILL never arrives first. ``FABRIC_SERVICE_SUPERVISOR=launchd`` tells the lock to back off
246
+ instead of exiting into a respawn loop.
247
+
248
+ The same refusals as the Node twin (finding 10): a label launchd cannot name, no program, an ExitTimeOut that does
249
+ not outlast the drain, an environment name ending TOKEN/SECRET/PASSWORD/KEY (a `*_FILE` path is fine), a value or
250
+ an argument shaped like a credential, and a control character in any string the plist carries."""
251
+ import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
252
+ if not _LABEL.match(str(label)):
253
+ raise ServiceError("label must match %s" % _LABEL.pattern)
254
+ argv = [str(a) for a in program_arguments]
255
+ if not argv:
256
+ raise ServiceError("A launchd job starts a program; ProgramArguments is empty.")
257
+ if not exit_timeout > DRAIN_SECONDS:
258
+ raise ServiceError("ExitTimeOut must outlast the %d s drain." % DRAIN_SECONDS)
259
+ env = dict(environment or {})
260
+ for key, value in env.items():
261
+ if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)\Z", key) and not key.endswith("_FILE"):
262
+ raise ServiceError("Environment variable %s looks like a secret; pass a *_FILE path instead." % key)
263
+ if fabric_secrets.secret_value(str(value)):
264
+ raise ServiceError("Environment %s holds a value shaped like a credential; a plist never carries one." % key)
265
+ for arg in argv:
266
+ if fabric_secrets.secret_argument(arg):
267
+ raise ServiceError("An argument carries a credential; a plist never does (read it from the token file or a secret reference).")
268
+ for value in [str(label), str(working_directory), str(stdout_path)] + argv + [str(x) for pair in env.items() for x in pair]:
269
+ _plain(value, "a plist string")
270
+ env.setdefault("PATH", "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin")
271
+ _no_marker_argument(argv)
272
+ env[SUPERVISOR_ENV] = "launchd"
273
+ plist = {
274
+ "Label": label,
275
+ "ProgramArguments": argv,
276
+ "WorkingDirectory": str(working_directory),
277
+ "RunAtLoad": True,
278
+ "KeepAlive": True,
279
+ "ThrottleInterval": 10,
280
+ "ExitTimeOut": exit_timeout,
281
+ # Standard, never Background: a host probes the service and agents call it while the Mac
282
+ # is busy; Background (and Nice/LowPriorityIO) lets macOS starve it for tens of seconds
283
+ # under load, and every host then reports an outage that never happened.
284
+ "ProcessType": "Standard",
285
+ "StandardOutPath": str(stdout_path),
286
+ "StandardErrorPath": str(stdout_path),
287
+ "EnvironmentVariables": env,
288
+ }
289
+ return plistlib.dumps(plist)
290
+
291
+
292
+ def _launchctl(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
293
+ return subprocess.run(["launchctl", *args], capture_output=True, text=True, timeout=timeout)
294
+
295
+
296
+ def _domain() -> str:
297
+ if sys.platform == "win32":
298
+ # DEC-0032: launchd is macOS's supervisor; Windows has Task Scheduler, and no uid.
299
+ raise ServiceError("launchd exists only on macOS; this system's supervisor is Task Scheduler.")
300
+ return "gui/%d" % os.getuid()
301
+
302
+
303
+ def launchd_loaded(label: str) -> bool:
304
+ return _launchctl("print", "%s/%s" % (_domain(), label)).returncode == 0
305
+
306
+
307
+ def fetch_well_known(origin: str, timeout: float = 2.0) -> Optional[Dict[str, Any]]:
308
+ try:
309
+ with urllib.request.urlopen(origin + "/.well-known/fabric-service", timeout=timeout) as response:
310
+ return json.loads(response.read().decode())
311
+ except (urllib.error.URLError, OSError, ValueError):
312
+ return None
313
+
314
+
315
+ _OVERRIDE_ROW = re.compile(r'^\s*"([^"]+)"\s*=>\s*(\S+)\s*\Z')
316
+
317
+
318
+ def launchd_override(label: str) -> Optional[str]:
319
+ """The operator's launchd override for ``label``: ``"disabled"``, ``"enabled"``, None when
320
+ none is recorded (a first install), or ``"unknown"`` when the table cannot be read.
321
+
322
+ Reads ``launchctl print-disabled gui/<uid>``; macOS 14+ prints ``=> disabled|enabled``,
323
+ older releases ``=> true|false`` (true means disabled)."""
324
+ result = _launchctl("print-disabled", _domain())
325
+ if result.returncode != 0:
326
+ return "unknown"
327
+ for line in result.stdout.splitlines():
328
+ match = _OVERRIDE_ROW.match(line)
329
+ if match and match.group(1) == label:
330
+ return "disabled" if match.group(2) in ("disabled", "true") else "enabled"
331
+ return None
332
+
333
+
334
+ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin: str,
335
+ service_id: str, instance: str = "default", timeout: float = 40.0,
336
+ force_enable: bool = False) -> Dict[str, Any]:
337
+ """Write + lint the plist, bootout and WAIT for unload, bootstrap (retry EIO 5), then
338
+ poll the well-known document until it answers with this identity.
339
+
340
+ The operator's intent wins (LC-14): only a first install (no override recorded) enables the
341
+ label. A label the operator disabled stays disabled and unloaded — the plist is still
342
+ rewritten, so the new release is what starts once they turn it back on — and the answer is
343
+ ``{"disabled": True, "label", "plist", "loaded"}`` instead of the well-known document.
344
+ ``force_enable`` is for an operator who asked for the service to be switched on."""
345
+ plist_path = Path(plist_path)
346
+ atomic_write(plist_path, plist_bytes, 0o644)
347
+ lint = subprocess.run(["plutil", "-lint", str(plist_path)], capture_output=True, text=True)
348
+ if lint.returncode != 0:
349
+ raise ServiceError("plist failed plutil -lint: %s" % lint.stdout.strip())
350
+ target = "%s/%s" % (_domain(), label)
351
+ override = launchd_override(label)
352
+ if force_enable or override is None:
353
+ _launchctl("enable", target)
354
+ elif override == "disabled":
355
+ return {"disabled": True, "label": label, "plist": str(plist_path), "loaded": launchd_loaded(label)}
356
+ _launchctl("bootout", target)
357
+ deadline = time.time() + 15
358
+ while launchd_loaded(label) and time.time() < deadline:
359
+ time.sleep(0.25)
360
+ last = ""
361
+ for _ in range(5):
362
+ result = _launchctl("bootstrap", _domain(), str(plist_path))
363
+ if result.returncode == 0:
364
+ break
365
+ last = (result.stderr or result.stdout).strip()
366
+ time.sleep(1.0)
367
+ else:
368
+ raise ServiceError("launchctl bootstrap failed: %s" % last)
369
+ deadline = time.time() + timeout
370
+ while time.time() < deadline:
371
+ doc = fetch_well_known(origin)
372
+ if doc and doc.get("service", {}).get("id") == service_id and doc.get("service", {}).get("instance") == instance:
373
+ return doc
374
+ time.sleep(0.5)
375
+ raise ServiceError("%s did not answer as %s.%s within %.0f s; read its log." % (origin, service_id, instance, timeout))
376
+
377
+
378
+ def launchd_uninstall(label: str, plist_path: Path, *, timeout: float = 30.0, purge: bool = False,
379
+ service_id: Optional[str] = None) -> Dict[str, Any]:
380
+ """Symmetric with ``launchd_install`` (LC-14): bootout and WAIT until the job is gone, remove
381
+ the plist, and reset the override to ``enabled`` — launchctl has no verb that deletes an
382
+ override, and a leftover ``disabled`` would make a later fresh install stay off. Data stays
383
+ unless ``purge`` (then ``service_id`` names whose data, logs and cache go). A job still
384
+ loaded at ``timeout`` raises, leaving the plist and data in place."""
385
+ if purge and not service_id:
386
+ raise ServiceError("purge needs the service id whose data, logs and cache to remove.")
387
+ target = "%s/%s" % (_domain(), label)
388
+ _launchctl("bootout", target)
389
+ deadline = time.monotonic() + timeout
390
+ while launchd_loaded(label):
391
+ if time.monotonic() >= deadline:
392
+ raise ServiceError("launchd job %s is still loaded %.0f s after bootout; nothing was removed." % (label, timeout))
393
+ time.sleep(0.1)
394
+ try:
395
+ Path(plist_path).unlink()
396
+ plist_removed = True
397
+ except FileNotFoundError:
398
+ plist_removed = False
399
+ _launchctl("enable", target)
400
+ purged: List[str] = []
401
+ if purge:
402
+ for directory in service_dirs(str(service_id)).values():
403
+ if directory.is_symlink():
404
+ directory.unlink()
405
+ elif directory.exists():
406
+ shutil.rmtree(directory)
407
+ else:
408
+ continue
409
+ purged.append(str(directory))
410
+ return {"unloaded": True, "plistRemoved": plist_removed, "override": "enabled", "purged": purged}
411
+
412
+
413
+ def prune_releases(releases_dir: Path, keep: int = 2, *, current: Optional[Path] = None) -> List[Path]:
414
+ """Keep the release the job runs and the one before it (LC-11 rollback, LC-15); remove
415
+ older release directories, newest first by modification time. ``current`` (a release or a
416
+ symlink to one) is never removed, even after a rollback to an older release. Symlinks and
417
+ files in ``releases_dir`` are left alone. Returns what was removed."""
418
+ if keep < 2:
419
+ raise ServiceError("keep at least the current release and the one before it (keep >= 2).")
420
+ root = Path(releases_dir)
421
+ if not root.is_dir():
422
+ return []
423
+ releases = [p for p in root.iterdir() if p.is_dir() and not p.is_symlink()]
424
+ releases.sort(key=lambda p: p.stat().st_mtime, reverse=True)
425
+ keepers: List[Path] = []
426
+ if current is not None:
427
+ resolved = Path(current).resolve()
428
+ keepers.extend(p for p in releases if p.resolve() == resolved)
429
+ for release in releases:
430
+ if len(keepers) >= keep:
431
+ break
432
+ if release not in keepers:
433
+ keepers.append(release)
434
+ removed = [p for p in releases if p not in keepers]
435
+ for release in removed:
436
+ shutil.rmtree(release)
437
+ return removed
@@ -0,0 +1,120 @@
1
+ """Dates and times the way the contract reads them, once for the whole kit (FAA-65, layer L0).
2
+
3
+ - ``parse_rfc3339_ms(text)``: strict RFC 3339 (a ``T``, an offset, a day that exists; no space, no leap second) as
4
+ milliseconds since the epoch, or NaN. ``estate_time`` in older code is the same function.
5
+ - ``clock_ms(at)``: a verifier's clock (a timezone-aware ``datetime`` or epoch seconds) as milliseconds, or NaN.
6
+ - ``utc_date_ms(text)``: a ``YYYY-MM-DD`` that exists as midnight UTC in milliseconds, or NaN.
7
+ - ``utc_day(ms)``, ``format_rfc3339(ms)``, ``day_window(today_ms, days)``, ``now_ms(clock)``: the helpers a service
8
+ needs to write dates and to walk the last N UTC days.
9
+
10
+ NaN, never an exception, is the answer for what cannot be read: callers compare it and get a finding.
11
+ Standard library only; imports only ``fabric_jsvalue``.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import datetime as _dt
16
+ import math
17
+ import re
18
+ from typing import Any, Callable, List, Optional
19
+ import sys as _sys # noqa: E402
20
+ from pathlib import Path as _Path # noqa: E402
21
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
22
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
23
+ from fabric_jsvalue import is_number
24
+
25
+
26
+ # ASCII digits only: Python's \d also matches other scripts' digits, which Date.parse never reads.
27
+ _RFC3339 = 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}))", re.ASCII)
28
+
29
+
30
+ def days_from_civil(year: int, month: int, day: int) -> int:
31
+ year -= month <= 2
32
+ era = (year if year >= 0 else year - 399) // 400
33
+ yoe = year - era * 400
34
+ doy = (153 * (month + (-3 if month > 2 else 9)) + 2) // 5 + day - 1
35
+ doe = yoe * 365 + yoe // 4 - yoe // 100 + doy
36
+ return era * 146097 + doe - 719468
37
+
38
+
39
+ def parse_rfc3339_ms(value: Any) -> float:
40
+ """An RFC 3339 date-time as milliseconds since the epoch, or NaN: `T`, an offset and a day that exists are required;
41
+ a space and a leap second `:60` are refused (ER-10). Fractions count to the millisecond, as Date.parse reads them."""
42
+ match = _RFC3339.fullmatch(value) if isinstance(value, str) else None
43
+ if not match:
44
+ return math.nan
45
+ year, month, day, hour, minute, second = (int(match.group(i)) for i in range(1, 7))
46
+ leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
47
+ days_in = [31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
48
+ if not (1 <= month <= 12 and 1 <= day <= days_in[month - 1]) or hour > 23 or minute > 59 or second > 59:
49
+ return math.nan
50
+ if match.group(9):
51
+ oh, om = int(match.group(10)), int(match.group(11))
52
+ if oh > 23 or om > 59:
53
+ return math.nan
54
+ offset = (oh * 60 + om) * (1 if match.group(9) == "+" else -1)
55
+ else:
56
+ offset = 0
57
+ millis = int((match.group(7) or ".")[1:4].ljust(3, "0"))
58
+ seconds = days_from_civil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second - offset * 60
59
+ return float(seconds * 1000 + millis)
60
+
61
+
62
+ def clock_ms(at: Any) -> float:
63
+ """The verifier's clock as milliseconds: a timezone-aware datetime or epoch seconds; NaN for anything else."""
64
+ if isinstance(at, _dt.datetime) and at.tzinfo is not None:
65
+ return at.timestamp() * 1000.0
66
+ if is_number(at):
67
+ try:
68
+ seconds = float(at)
69
+ except OverflowError:
70
+ return math.nan
71
+ return seconds * 1000.0 if math.isfinite(seconds) else math.nan
72
+ return math.nan
73
+
74
+
75
+ DAY_MS = 86_400_000
76
+ _UTC_DATE = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})", re.ASCII)
77
+
78
+
79
+ def utc_date_ms(value: Any) -> float:
80
+ """A ``YYYY-MM-DD`` date that exists, as midnight UTC in milliseconds; NaN for anything else (the contract's
81
+ ``utcDate``)."""
82
+ match = _UTC_DATE.fullmatch(value) if isinstance(value, str) else None
83
+ if not match:
84
+ return math.nan
85
+ try:
86
+ day = _dt.date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
87
+ except ValueError:
88
+ return math.nan
89
+ return float((day - _dt.date(1970, 1, 1)).days * DAY_MS)
90
+
91
+
92
+ def utc_day(ms: float) -> str:
93
+ """The UTC calendar day (``YYYY-MM-DD``) of an instant in milliseconds. Raises ValueError for NaN or Infinity."""
94
+ if not math.isfinite(ms):
95
+ raise ValueError("not a finite instant")
96
+ return (_dt.datetime(1970, 1, 1, tzinfo=_dt.timezone.utc) + _dt.timedelta(milliseconds=ms)).strftime("%Y-%m-%d")
97
+
98
+
99
+ def format_rfc3339(ms: float) -> str:
100
+ """An instant as RFC 3339 in UTC with milliseconds (``2026-10-21T09:00:00.000Z``), which ``parse_rfc3339_ms``
101
+ reads back to the same value. Raises ValueError for NaN or Infinity."""
102
+ if not math.isfinite(ms):
103
+ raise ValueError("not a finite instant")
104
+ moment = _dt.datetime(1970, 1, 1, tzinfo=_dt.timezone.utc) + _dt.timedelta(milliseconds=int(ms))
105
+ return moment.strftime("%Y-%m-%dT%H:%M:%S.") + "%03dZ" % (moment.microsecond // 1000)
106
+
107
+
108
+ def day_window(today_ms: float, days: int) -> List[str]:
109
+ """The last ``days`` UTC days ending with the day of ``today_ms``, oldest first (a usage report's window is 31)."""
110
+ if days < 1:
111
+ return []
112
+ start = (today_ms // DAY_MS) * DAY_MS - (days - 1) * DAY_MS
113
+ return [utc_day(start + i * DAY_MS) for i in range(days)]
114
+
115
+
116
+ def now_ms(clock: Optional[Callable[[], float]] = None) -> float:
117
+ """Milliseconds since the epoch: from ``clock`` (seconds, as ``time.time``) when given, so a test fixes the time."""
118
+ import time
119
+ return float((clock or time.time)()) * 1000.0
120
+