@crvouga/dev-fleet 0.0.0-stage → 0.1.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 (333) hide show
  1. package/.mise/locks/aws-cdk/2.1146.1/aube-lock.yaml +24 -0
  2. package/.mise/locks/aws-cdk/2.1146.1/package.json +7 -0
  3. package/.mise/locks/azure-cli/2.91.0~a837e014/pyproject.toml +5 -0
  4. package/.mise/locks/azure-cli/2.91.0~a837e014/uv.lock +2794 -0
  5. package/.mise/locks/gemini/0.66.0~84aac8e6/aube-lock.yaml +123 -0
  6. package/.mise/locks/gemini/0.66.0~84aac8e6/package.json +12 -0
  7. package/.mise/locks/npm-devcontainers-cli/0.90.0/aube-lock.yaml +24 -0
  8. package/.mise/locks/npm-devcontainers-cli/0.90.0/package.json +7 -0
  9. package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/aube-lock.yaml +1432 -0
  10. package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/package.json +7 -0
  11. package/.mise/locks/npm-vercel/65.8.0/aube-lock.yaml +3595 -0
  12. package/.mise/locks/npm-vercel/65.8.0/package.json +7 -0
  13. package/.mise/locks/npm-wrangler/4.156.0/aube-lock.yaml +895 -0
  14. package/.mise/locks/npm-wrangler/4.156.0/package.json +7 -0
  15. package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/aube-lock.yaml +24 -0
  16. package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/package.json +7 -0
  17. package/LICENSE +21 -0
  18. package/README.md +157 -2
  19. package/agents/antigravity-proxy.ts +3 -0
  20. package/agents/audit.md +89 -0
  21. package/agents/claude-proxy +5 -0
  22. package/agents/claude-proxy.ts +3 -0
  23. package/agents/codex-proxy.ts +3 -0
  24. package/agents/grok-proxy.ts +3 -0
  25. package/agents/managed-proxy.ts +55 -0
  26. package/agents/opencode-proxy.ts +3 -0
  27. package/agents/registry.json +209 -0
  28. package/agents/registry.schema.json +149 -0
  29. package/agents/setup.md +128 -0
  30. package/agents/t3.json +40 -0
  31. package/ansible/macos/manager.yml +30 -0
  32. package/ansible/macos/policy.json +17 -0
  33. package/ansible/monitoring/README.md +154 -0
  34. package/ansible/monitoring/agent-darwin.yml +198 -0
  35. package/ansible/monitoring/agent.yml +134 -0
  36. package/ansible/monitoring/beszel-agent.service +42 -0
  37. package/ansible/monitoring/compose.yml +74 -0
  38. package/ansible/monitoring/hub-darwin.yml +102 -0
  39. package/ansible/monitoring/lock.json +11 -0
  40. package/ansible/net/remote-probe.sh +13 -0
  41. package/ansible/ubuntu/authorize-sudo.sh +51 -0
  42. package/ansible/ubuntu/baseline.yml +89 -0
  43. package/ansible/ubuntu/disk-guard.py +104 -0
  44. package/ansible/ubuntu/policy.json +43 -0
  45. package/ansible/ubuntu/recovery.yml +15 -0
  46. package/ansible/ubuntu/t3/policy.json +7 -0
  47. package/ansible/ubuntu/t3-desktop.yml +220 -0
  48. package/ansible/ubuntu/t3-health.sh +33 -0
  49. package/ansible/ubuntu/t3-policy.yml +76 -0
  50. package/ansible/ubuntu/t3-route.yml +28 -0
  51. package/ansible/ubuntu/tailscale.yml +75 -0
  52. package/ansible/ubuntu/tasks/credentials.yml +47 -0
  53. package/ansible/ubuntu/tasks/docker.yml +31 -0
  54. package/ansible/ubuntu/tasks/packages.yml +56 -0
  55. package/ansible/ubuntu/tasks/power.yml +51 -0
  56. package/ansible/ubuntu/tasks/recovery.yml +39 -0
  57. package/ansible/ubuntu/tasks/resources.yml +252 -0
  58. package/ansible/ubuntu/tasks/services.yml +31 -0
  59. package/ansible/ubuntu/tasks/ssh.yml +71 -0
  60. package/ansible/ubuntu/tasks/user.yml +120 -0
  61. package/bin/devfleet.mjs +36 -0
  62. package/bootstrap/bootstrap.ps1 +50 -0
  63. package/bootstrap/bootstrap.sh +73 -0
  64. package/bootstrap/devfleet +7 -0
  65. package/bootstrap/docker-desktop.sh +33 -0
  66. package/bootstrap/doctor.ts +189 -0
  67. package/bootstrap/fleet-manager-run.sh +76 -0
  68. package/bootstrap/fleet-remote-bootstrap.sh +185 -0
  69. package/bootstrap/global-config.ts +113 -0
  70. package/bootstrap/install-launcher.sh +7 -0
  71. package/bootstrap/native-aws.ts +86 -0
  72. package/bootstrap/native-t3.ts +154 -0
  73. package/bootstrap/npm.ts +350 -0
  74. package/bootstrap/setup-status.ts +51 -0
  75. package/bootstrap/setup.ts +88 -0
  76. package/bootstrap/shell-path.ts +105 -0
  77. package/bootstrap/verify-t3-archive.py +49 -0
  78. package/bootstrap/worker-auth.ts +65 -0
  79. package/bootstrap/worker-github.ts +47 -0
  80. package/bootstrap/worker-tools.ts +24 -0
  81. package/cliproxy/AGENTS.md +11 -0
  82. package/cliproxy/README.md +269 -0
  83. package/cliproxy/clients/bootstrap.py +143 -0
  84. package/cliproxy/clients/launcher.py +57 -0
  85. package/cliproxy/compose.yaml +90 -0
  86. package/cliproxy/config.template.yaml +70 -0
  87. package/cliproxy/docs/cloud-adoption.md +16 -0
  88. package/cliproxy/docs/deployment.png +0 -0
  89. package/cliproxy/docs/deployment.svg +109 -0
  90. package/cliproxy/docs/health-workflow.example.yml +185 -0
  91. package/cliproxy/docs/migration.md +131 -0
  92. package/cliproxy/docs/model-health-report.schema.json +364 -0
  93. package/cliproxy/docs/model-health.md +393 -0
  94. package/cliproxy/image.lock.json +5 -0
  95. package/cliproxy/launchd/dev.devfleet.cliproxy-backup-check.plist +38 -0
  96. package/cliproxy/launchd/dev.devfleet.cliproxy-backup.plist +48 -0
  97. package/cliproxy/launchd/dev.devfleet.cliproxy-fleet.plist +32 -0
  98. package/cliproxy/launchd/dev.devfleet.cliproxy-gc.plist +38 -0
  99. package/cliproxy/launchd/dev.devfleet.cliproxy-monitor.plist +29 -0
  100. package/cliproxy/launchd/dev.devfleet.cliproxy-vault-renew.plist +31 -0
  101. package/cliproxy/models.json +1618 -0
  102. package/cliproxy/requirements.lock +78 -0
  103. package/cliproxy/scripts/accounts.py +38 -0
  104. package/cliproxy/scripts/audit.py +123 -0
  105. package/cliproxy/scripts/backup +234 -0
  106. package/cliproxy/scripts/check.py +34 -0
  107. package/cliproxy/scripts/checkpoint.py +226 -0
  108. package/cliproxy/scripts/cli.py +64 -0
  109. package/cliproxy/scripts/cliproxy_host.py +257 -0
  110. package/cliproxy/scripts/cloud_state.py +150 -0
  111. package/cliproxy/scripts/container-health +6 -0
  112. package/cliproxy/scripts/docker +24 -0
  113. package/cliproxy/scripts/external-health.py +79 -0
  114. package/cliproxy/scripts/fleet_config.py +43 -0
  115. package/cliproxy/scripts/fleet_sync.py +274 -0
  116. package/cliproxy/scripts/initialize +84 -0
  117. package/cliproxy/scripts/install-dependencies +48 -0
  118. package/cliproxy/scripts/install.py +406 -0
  119. package/cliproxy/scripts/model_health.py +16 -0
  120. package/cliproxy/scripts/modelhealth/__init__.py +5 -0
  121. package/cliproxy/scripts/modelhealth/checks.py +429 -0
  122. package/cliproxy/scripts/modelhealth/cli.py +117 -0
  123. package/cliproxy/scripts/modelhealth/config.py +186 -0
  124. package/cliproxy/scripts/modelhealth/emulator.py +536 -0
  125. package/cliproxy/scripts/modelhealth/expect.py +88 -0
  126. package/cliproxy/scripts/modelhealth/findings.py +183 -0
  127. package/cliproxy/scripts/modelhealth/protocol.py +330 -0
  128. package/cliproxy/scripts/modelhealth/redact.py +28 -0
  129. package/cliproxy/scripts/modelhealth/report.py +150 -0
  130. package/cliproxy/scripts/modelhealth/runner.py +390 -0
  131. package/cliproxy/scripts/modelhealth/scenario.py +131 -0
  132. package/cliproxy/scripts/modelhealth/schema.py +63 -0
  133. package/cliproxy/scripts/modelhealth/simulate.py +111 -0
  134. package/cliproxy/scripts/modelhealth/transport.py +213 -0
  135. package/cliproxy/scripts/monitor +68 -0
  136. package/cliproxy/scripts/ops +527 -0
  137. package/cliproxy/scripts/public-endpoint +127 -0
  138. package/cliproxy/scripts/recover_node.py +177 -0
  139. package/cliproxy/scripts/restore-verify +60 -0
  140. package/cliproxy/scripts/retention.py +144 -0
  141. package/cliproxy/scripts/service-start +7 -0
  142. package/cliproxy/scripts/validate-config +56 -0
  143. package/cliproxy/scripts/vault-access.py +57 -0
  144. package/cliproxy/scripts/vault-backup-login +55 -0
  145. package/cliproxy/scripts/vault-recovery +56 -0
  146. package/cliproxy/scripts/vault-renew +30 -0
  147. package/cliproxy/systemd/cliproxy-backup-check.service +10 -0
  148. package/cliproxy/systemd/cliproxy-backup-check.timer +8 -0
  149. package/cliproxy/systemd/cliproxy-backup.service +13 -0
  150. package/cliproxy/systemd/cliproxy-backup.timer +8 -0
  151. package/cliproxy/systemd/cliproxy-fleet.service +17 -0
  152. package/cliproxy/systemd/cliproxy-gc.service +13 -0
  153. package/cliproxy/systemd/cliproxy-gc.timer +8 -0
  154. package/cliproxy/systemd/cliproxy-monitor.service +8 -0
  155. package/cliproxy/systemd/cliproxy-monitor.timer +7 -0
  156. package/cliproxy/systemd/cliproxy-vault-renew.service +8 -0
  157. package/cliproxy/systemd/cliproxy-vault-renew.timer +7 -0
  158. package/cliproxy/vendor/LICENSE +22 -0
  159. package/cliproxy/vendor/config.upstream.yaml +1349 -0
  160. package/examples/fleet.json +95 -0
  161. package/examples/roster.json +13 -0
  162. package/mise.lock +1492 -0
  163. package/mise.toml +99 -0
  164. package/package.json +65 -4
  165. package/schema/fleet.schema.json +364 -0
  166. package/schema/roster.schema.json +77 -0
  167. package/src/agents/account-relay.ts +111 -0
  168. package/src/agents/accounts.ts +85 -0
  169. package/src/agents/audit.ts +77 -0
  170. package/src/agents/cli.ts +13 -0
  171. package/src/agents/command.ts +204 -0
  172. package/src/agents/converge.ts +123 -0
  173. package/src/agents/doctor.ts +107 -0
  174. package/src/agents/errors.ts +2 -0
  175. package/src/agents/files.ts +73 -0
  176. package/src/agents/fleet-verify.ts +156 -0
  177. package/src/agents/launch.ts +290 -0
  178. package/src/agents/native-verify.ts +226 -0
  179. package/src/agents/provider-errors.ts +50 -0
  180. package/src/agents/registry.ts +55 -0
  181. package/src/agents/render.ts +110 -0
  182. package/src/agents/round-trip.ts +192 -0
  183. package/src/agents/secrets.ts +13 -0
  184. package/src/agents/toolchain.ts +34 -0
  185. package/src/agents/verify-cache.ts +103 -0
  186. package/src/agents/verify.ts +127 -0
  187. package/src/auth-checks.ts +163 -0
  188. package/src/cli/agent-guide.ts +95 -0
  189. package/src/cli/app.ts +115 -0
  190. package/src/cli/cli.ts +21 -0
  191. package/src/cli/command-index.ts +50 -0
  192. package/src/cli/contracts.ts +25 -0
  193. package/src/cli/fleet-reference.ts +17 -0
  194. package/src/cli/fleet-sync-checkout.ts +91 -0
  195. package/src/cli/fleet-sync-enrollment.ts +144 -0
  196. package/src/cli/fleet-sync-forward.ts +134 -0
  197. package/src/cli/fleet-sync-manager.ts +193 -0
  198. package/src/cli/fleet-sync-monitoring.ts +185 -0
  199. package/src/cli/fleet-sync-output.ts +314 -0
  200. package/src/cli/fleet-sync-privilege.ts +27 -0
  201. package/src/cli/fleet-sync-progress.ts +109 -0
  202. package/src/cli/fleet-sync-remote.ts +236 -0
  203. package/src/cli/fleet-sync-report.ts +26 -0
  204. package/src/cli/fleet-sync-schedule.ts +138 -0
  205. package/src/cli/fleet-sync-vault.ts +166 -0
  206. package/src/cli/fleet-sync.ts +309 -0
  207. package/src/cli/fleet.ts +163 -0
  208. package/src/cli/managed-health.ts +219 -0
  209. package/src/cli/node-deployment.ts +34 -0
  210. package/src/cli/node-sync.ts +114 -0
  211. package/src/cli/node-t3.ts +31 -0
  212. package/src/cli/options.ts +141 -0
  213. package/src/cli/probe-source.ts +7 -0
  214. package/src/cli/program.ts +169 -0
  215. package/src/cli/register-fleet.ts +246 -0
  216. package/src/cli/registry.ts +158 -0
  217. package/src/cli/repair-apply.ts +67 -0
  218. package/src/cli/repair-guard.ts +11 -0
  219. package/src/cli/repair-inspect.ts +150 -0
  220. package/src/cli/repair-script.ts +76 -0
  221. package/src/cli/repair.ts +209 -0
  222. package/src/cli/roster.ts +89 -0
  223. package/src/cli/search.ts +35 -0
  224. package/src/cli/settings.ts +64 -0
  225. package/src/cli/source-operations.ts +78 -0
  226. package/src/cli/t3/command.ts +90 -0
  227. package/src/cli/t3/register.ts +55 -0
  228. package/src/cli/tailnet.ts +127 -0
  229. package/src/cli/ui-actions.ts +133 -0
  230. package/src/cli/ui-controller.ts +113 -0
  231. package/src/cli/ui-data.ts +106 -0
  232. package/src/cli/ui-execute.ts +163 -0
  233. package/src/cli/ui-repairs.ts +34 -0
  234. package/src/cli/ui-reports.ts +211 -0
  235. package/src/cli/ui-targets.ts +100 -0
  236. package/src/cli/ui-terminal.ts +109 -0
  237. package/src/cli/ui-types.ts +49 -0
  238. package/src/cli/ui-wizard.ts +101 -0
  239. package/src/cli/ui.ts +9 -0
  240. package/src/cli/worker-health-output.ts +37 -0
  241. package/src/config.ts +186 -0
  242. package/src/configure.ts +255 -0
  243. package/src/definition.ts +260 -0
  244. package/src/diagnostics.ts +92 -0
  245. package/src/doctor-check.ts +10 -0
  246. package/src/errors.ts +1 -0
  247. package/src/fingerprint.ts +120 -0
  248. package/src/fleet-config.ts +88 -0
  249. package/src/index.ts +6 -0
  250. package/src/input-hashes.ts +73 -0
  251. package/src/machine/local-source.ts +52 -0
  252. package/src/machine/lock-error.ts +2 -0
  253. package/src/machine/platform.ts +44 -0
  254. package/src/machine/source.ts +68 -0
  255. package/src/machine/state.ts +185 -0
  256. package/src/machine-health.ts +95 -0
  257. package/src/managed-source.ts +47 -0
  258. package/src/manager-failure.ts +48 -0
  259. package/src/manager-sync.ts +400 -0
  260. package/src/manager.ts +62 -0
  261. package/src/monitoring/apply.ts +171 -0
  262. package/src/monitoring/beszel.ts +191 -0
  263. package/src/monitoring/enroll.ts +110 -0
  264. package/src/monitoring/hub-access.ts +113 -0
  265. package/src/monitoring/hub-viewer.ts +142 -0
  266. package/src/monitoring/hub.ts +93 -0
  267. package/src/monitoring/manager-agent.ts +73 -0
  268. package/src/monitoring/manager-hub.ts +232 -0
  269. package/src/monitoring/remote.ts +65 -0
  270. package/src/network-definition.ts +83 -0
  271. package/src/node-converge-linux.ts +372 -0
  272. package/src/node-converge-shared.ts +83 -0
  273. package/src/node-converge.ts +40 -0
  274. package/src/onboard.ts +35 -0
  275. package/src/platform/ansible-receipt.ts +128 -0
  276. package/src/platform/ansible.ts +62 -0
  277. package/src/platform/darwin.ts +203 -0
  278. package/src/platform/detect.ts +11 -0
  279. package/src/platform/fallback.ts +110 -0
  280. package/src/platform/index.ts +38 -0
  281. package/src/platform/launchd.ts +98 -0
  282. package/src/platform/linux.ts +139 -0
  283. package/src/platform/ps.ts +45 -0
  284. package/src/platform/resource-verify.ts +128 -0
  285. package/src/platform/systemd-user.ts +95 -0
  286. package/src/platform/t3-policy.ts +137 -0
  287. package/src/platform/t3-route.ts +68 -0
  288. package/src/platform/t3-service.ts +75 -0
  289. package/src/platform/types.ts +123 -0
  290. package/src/platform/ubuntu-audit.ts +63 -0
  291. package/src/platform/ubuntu-verify.ts +105 -0
  292. package/src/platform/windows.ts +148 -0
  293. package/src/power-recovery.ts +55 -0
  294. package/src/probe.ts +51 -0
  295. package/src/protocol.ts +119 -0
  296. package/src/proxy-converge.ts +85 -0
  297. package/src/remote-bootstrap.ts +212 -0
  298. package/src/remote-entry.ts +89 -0
  299. package/src/remote-lock.ts +12 -0
  300. package/src/repair-catalog.ts +206 -0
  301. package/src/repair-plan.ts +78 -0
  302. package/src/repair-runner.ts +84 -0
  303. package/src/report.ts +71 -0
  304. package/src/runner.ts +104 -0
  305. package/src/secrets.ts +169 -0
  306. package/src/service-auth.ts +115 -0
  307. package/src/ssh.ts +135 -0
  308. package/src/sync.ts +104 -0
  309. package/src/t3/accounts.ts +81 -0
  310. package/src/t3/desired.ts +165 -0
  311. package/src/t3/files.ts +81 -0
  312. package/src/t3/names-check.ts +53 -0
  313. package/src/t3/names.ts +155 -0
  314. package/src/t3/plan.ts +82 -0
  315. package/src/t3/providers.ts +74 -0
  316. package/src/t3/reconcile.ts +123 -0
  317. package/src/t3-endpoint.ts +48 -0
  318. package/src/t3-linked-check.ts +101 -0
  319. package/src/tailnet-health.ts +231 -0
  320. package/src/tailnet-matching.ts +37 -0
  321. package/src/tailscale.ts +141 -0
  322. package/src/test-fixtures.ts +36 -0
  323. package/src/toolchain-checks.ts +83 -0
  324. package/src/topology.ts +24 -0
  325. package/src/transport.ts +122 -0
  326. package/src/types.ts +95 -0
  327. package/src/vault-config.ts +38 -0
  328. package/src/worker-access-checks.ts +115 -0
  329. package/src/worker-capacity.ts +34 -0
  330. package/src/worker-host-checks.ts +176 -0
  331. package/src/worker-policy.ts +64 -0
  332. package/src/worker-readiness.ts +64 -0
  333. package/src/workstation-checks.ts +29 -0
@@ -0,0 +1,226 @@
1
+ """Consistent, secret-minimal checkpoints. Runtime state is never executable code."""
2
+
3
+ import fnmatch
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import pathlib
8
+ import shutil
9
+ import stat
10
+ import subprocess
11
+ import time
12
+
13
+ STATE_PATHS = (
14
+ "runtime/config.yaml",
15
+ "runtime/auths",
16
+ "runtime/static/management.html",
17
+ "tailscale",
18
+ "endpoint.json",
19
+ "public-endpoint-enabled",
20
+ "repository.json",
21
+ "status/gc-success.json",
22
+ )
23
+
24
+
25
+ def regular_tree(path):
26
+ """Reject links/devices before copying or publishing recovered state."""
27
+ mode = path.lstat().st_mode
28
+ if stat.S_ISDIR(mode):
29
+ for child in path.iterdir():
30
+ regular_tree(child)
31
+ elif not stat.S_ISREG(mode):
32
+ raise ValueError("Checkpoint must contain only regular files/directories")
33
+
34
+
35
+ def hashes(root):
36
+ regular_tree(root)
37
+ return {
38
+ str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest()
39
+ for path in sorted(root.rglob("*"))
40
+ if path.is_file() and path != root / "CHECKPOINT.json"
41
+ }
42
+
43
+
44
+ # Upstream writes these beside credentials; they are never checkpointed.
45
+ AUTH_TRANSIENT = ("*.lock", "logs")
46
+
47
+
48
+ def validate(root):
49
+ regular_tree(root)
50
+ manifest = json.loads((root / "CHECKPOINT.json").read_text())
51
+ if manifest.get("version") != 1 or manifest.get("files") != hashes(root):
52
+ raise ValueError("Checkpoint incomplete or changed")
53
+ state = root / "state"
54
+ for path in root.rglob("*"):
55
+ if not path.is_file():
56
+ continue
57
+ name = str(path.relative_to(root))
58
+ if name in ("CHECKPOINT.json", "INSTALLATION.json"):
59
+ continue
60
+ if not any(
61
+ name == "state/" + allowed or name.startswith("state/" + allowed + "/")
62
+ for allowed in STATE_PATHS
63
+ ):
64
+ raise ValueError("Unexpected checkpoint file")
65
+ if not (state / "runtime/static/management.html").is_file():
66
+ raise ValueError("Dashboard missing")
67
+ if not (state / "runtime/auths").is_dir():
68
+ raise ValueError("Provider cache directory missing")
69
+ for path in (state / "runtime/auths").iterdir():
70
+ if path.name == "kv.db" and path.is_file() and path.stat().st_size > 0:
71
+ continue # Opaque upstream bbolt state, captured while the process is stopped.
72
+ # .json: provider credential; .cds: upstream per-account cooldown
73
+ # records (JSON object, no credential), kept so limits survive moves.
74
+ if path.suffix not in (".json", ".cds") or not path.is_file():
75
+ raise ValueError("Unexpected provider file")
76
+ if not isinstance(json.loads(path.read_text()), dict):
77
+ raise ValueError("Provider credential must be an object")
78
+ subprocess.run(
79
+ [
80
+ str(pathlib.Path(__file__).parent / "validate-config"),
81
+ str(state / "runtime/config.yaml"),
82
+ ],
83
+ check=True,
84
+ capture_output=True,
85
+ )
86
+ return manifest
87
+
88
+
89
+ def stamps(root):
90
+ """Size and modification time of every state file whose copy must be whole:
91
+ the runtime tree and the Tailscale identity. Changing logs are not state."""
92
+ found = {}
93
+ for name in ("runtime", "tailscale/tailscaled.state"):
94
+ source = root / "state" / name
95
+ paths = [source] if source.is_file() else source.rglob("*") if source.is_dir() else []
96
+ for path in paths:
97
+ if path.is_file() and not any(
98
+ fnmatch.fnmatch(part, pattern)
99
+ for part in path.relative_to(root).parts
100
+ for pattern in AUTH_TRANSIENT
101
+ ):
102
+ status = path.stat()
103
+ found[str(path)] = (status.st_size, status.st_mtime_ns)
104
+ return found
105
+
106
+
107
+ def copy_state(root, target):
108
+ target.mkdir(mode=0o700)
109
+ (target / "state").mkdir(mode=0o700)
110
+ for name in STATE_PATHS:
111
+ source = root / "state" / name
112
+ if not source.exists() and not source.is_symlink():
113
+ continue
114
+ regular_tree(source)
115
+ destination = target / "state" / name
116
+ destination.parent.mkdir(parents=True, exist_ok=True)
117
+ if source.is_dir():
118
+ # Process-local locks and upstream error logs (request
119
+ # content) are never restored.
120
+ ignore = (
121
+ shutil.ignore_patterns(*AUTH_TRANSIENT)
122
+ if name == "runtime/auths"
123
+ else None
124
+ )
125
+ shutil.copytree(source, destination, ignore=ignore)
126
+ else:
127
+ shutil.copyfile(source, destination)
128
+ shutil.copyfile(root / "INSTALLATION.json", target / "INSTALLATION.json")
129
+ (target / "CHECKPOINT.json").write_text(
130
+ json.dumps({"version": 1, "files": hashes(target)}) + "\n"
131
+ )
132
+ validate(target)
133
+
134
+
135
+ def capture(root, target, attempts=6, sleep=time.sleep):
136
+ """Copies the state of the running stack without ever stopping it: agents
137
+ use the proxy around the clock, and a stopped connector fails every one of
138
+ their requests. A copy is consistent when no state file changed while it
139
+ was taken (the proxy writes a file, including its bbolt store, in one
140
+ synced step); otherwise it is discarded and taken again. A stack that
141
+ never holds still fails the backup, which the monitor reports."""
142
+ for attempt in range(attempts):
143
+ before = stamps(root)
144
+ try:
145
+ copy_state(root, target)
146
+ if stamps(root) == before:
147
+ return
148
+ except (OSError, ValueError):
149
+ # A file replaced mid-copy; only the last attempt's error counts.
150
+ if attempt + 1 == attempts:
151
+ shutil.rmtree(target, ignore_errors=True)
152
+ raise
153
+ shutil.rmtree(target, ignore_errors=True)
154
+ sleep(2)
155
+ raise ValueError("State kept changing during capture")
156
+
157
+
158
+ def locate(restored):
159
+ """Restic retains the source absolute path; never assume a source hostname/path."""
160
+ candidates = list(restored.rglob("CHECKPOINT.json"))
161
+ if len(candidates) != 1:
162
+ raise ValueError(
163
+ "Expected exactly one modern checkpoint; use manual legacy recovery"
164
+ )
165
+ root = candidates[0].parent
166
+ validate(root)
167
+ return root
168
+
169
+
170
+ def hydrate(checkpoint, state):
171
+ """Populate an empty cache only. A crash marker prevents partial-cache startup."""
172
+ validate(checkpoint)
173
+ names = (
174
+ "runtime",
175
+ "tailscale",
176
+ "endpoint.json",
177
+ "public-endpoint-enabled",
178
+ "repository.json",
179
+ )
180
+ repository = state / "repository.json"
181
+ if repository.exists() or repository.is_symlink():
182
+ regular_tree(repository)
183
+ saved = checkpoint / "state/repository.json"
184
+ if not saved.is_file() or json.loads(repository.read_text()) != json.loads(
185
+ saved.read_text()
186
+ ):
187
+ raise ValueError("Existing repository identity differs from checkpoint")
188
+ names = tuple(name for name in names if name != "repository.json")
189
+ if any((state / name).exists() or (state / name).is_symlink() for name in names):
190
+ raise ValueError(
191
+ "Recovery requires an empty cache; preserve or remove existing cache explicitly"
192
+ )
193
+ marker = state / "recovery-incomplete"
194
+ if marker.exists():
195
+ raise ValueError("Interrupted recovery requires inspection")
196
+ marker.write_text("Recovery in progress; do not start services.\n")
197
+ created = []
198
+ try:
199
+ for name in names:
200
+ source = checkpoint / "state" / name
201
+ if not source.exists():
202
+ continue
203
+ destination = state / name
204
+ created.append(destination)
205
+ if source.is_dir():
206
+ shutil.copytree(source, destination)
207
+ else:
208
+ shutil.copyfile(source, destination)
209
+ paths = (
210
+ [destination, *destination.rglob("*")]
211
+ if destination.is_dir()
212
+ else [destination]
213
+ )
214
+ for path in paths:
215
+ path.chmod(0o700 if path.is_dir() else 0o600)
216
+ if os.geteuid() == 0:
217
+ os.chown(path, 1000, 1000)
218
+ marker.unlink()
219
+ except BaseException:
220
+ for destination in reversed(created):
221
+ if destination.is_dir():
222
+ shutil.rmtree(destination)
223
+ else:
224
+ destination.unlink(missing_ok=True)
225
+ marker.unlink()
226
+ raise
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env python3
2
+ """Repository entry point for installing and operating the local service."""
3
+
4
+ import sys
5
+
6
+ sys.dont_write_bytecode = True
7
+
8
+ import pathlib as _pathlib
9
+
10
+ sys.path.insert(0, str(_pathlib.Path(__file__).resolve().parent))
11
+ import cliproxy_host
12
+
13
+
14
+ import os
15
+ import pathlib
16
+ import subprocess
17
+ import sys
18
+
19
+ source = pathlib.Path(__file__).resolve().parent
20
+ root = cliproxy_host.default_root().resolve()
21
+ args = sys.argv[1:]
22
+ if not args or args[0] in ["help", "--help", "-h"]:
23
+ print(
24
+ "Usage: devfleet cliproxy COMMAND\nCommands: install [--revision REV|--rollback|--dry-run|--prepare-only], audit, public-endpoint [enable|start|disable|verify], status, start, stop, restart, health, models, logs, validate, apply FILE, add-account PROVIDER, inference PROTOCOL MODEL, model-health [--profile liveness|readiness|light|nightly|manual] [--suite core|cursor|all] [--target live|mock], model-suite (same flags; defaults to nightly), backup, gc, snapshots, check, check-full, restore SNAPSHOT EMPTY_DIRECTORY, restore-cache --previous-node-stopped, vault-login, recover, recover-node NEW_DIRECTORY [--vault-token-file FILE], verify-recovery, portable-login, activate, drain"
25
+ )
26
+ sys.exit(0)
27
+ command, *rest = args
28
+ if command == "install":
29
+ invocation = [sys.executable, str(source / "install.py"), *rest]
30
+ # The legacy Linux host installs system units; the macOS manager is per-user.
31
+ linux = cliproxy_host.system() == "linux"
32
+ if linux and os.geteuid() != 0 and "--dry-run" not in rest:
33
+ invocation = ["sudo", *invocation]
34
+ elif command in ["model-health", "model-suite"]:
35
+ invocation = [sys.executable, "-B", str(source / "model_health.py"), command, *rest]
36
+ elif command == "recover-node":
37
+ invocation = [sys.executable, "-B", str(root / "scripts/recover_node.py"), *rest]
38
+ elif command == "public-endpoint":
39
+ invocation = [str(root / "scripts/public-endpoint"), *rest]
40
+ elif command == "audit":
41
+ invocation = [sys.executable, str(root / "scripts/audit.py"), *rest]
42
+ elif command in [
43
+ "backup",
44
+ "gc",
45
+ "snapshots",
46
+ "check",
47
+ "check-full",
48
+ "restore",
49
+ "restore-cache",
50
+ ]:
51
+ invocation = [str(root / "scripts/backup"), command, *rest]
52
+ elif command in ["vault-login", "recover", "verify-recovery"]:
53
+ invocation = [
54
+ str(
55
+ root
56
+ / "scripts"
57
+ / ("vault-backup-login" if command == "vault-login" else "vault-recovery")
58
+ )
59
+ ]
60
+ if command != "vault-login":
61
+ invocation.append("recover" if command == "recover" else "verify")
62
+ else:
63
+ invocation = [str(root / "scripts/ops"), command, *rest]
64
+ raise SystemExit(subprocess.call(invocation))
@@ -0,0 +1,257 @@
1
+ """Explicit host dispatch for the singleton proxy.
2
+
3
+ The manager (macOS) hosts the proxy with per-user launchd agents and Docker
4
+ Desktop; no sudo. Linux systemd hosting is legacy and kept for fencing and
5
+ decommissioning the previous owner. Everything here is pure or takes injected
6
+ commands so it can be tested offline.
7
+ """
8
+
9
+ import sys
10
+
11
+ sys.dont_write_bytecode = True
12
+
13
+ import os
14
+ import pathlib
15
+ import re
16
+ import socket
17
+ import subprocess
18
+
19
+ LINUX_ROOT = pathlib.Path("/opt/cliproxy-fleet")
20
+ # The tracked LaunchAgents reference this path relative to $HOME.
21
+ MACOS_ROOT = "Library/Application Support/cliproxy-fleet"
22
+ LINUX_IDS = (1000, 1000)
23
+ # Base PATH of the tracked LaunchAgents. Each agent puts the install's
24
+ # tools/bin (pinned python3 + PyYAML venv, pinned restic) in front of it.
25
+ LAUNCHD_PATH = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Applications/Docker.app/Contents/Resources/bin"
26
+ LAUNCHD_TOOLS = "$HOME/Library/Application Support/cliproxy-fleet/tools/bin"
27
+ DOCKER_DESKTOP_BIN = "/Applications/Docker.app/Contents/Resources/bin"
28
+ DOCKER_UNAVAILABLE = 69 # EX_UNAVAILABLE
29
+
30
+ SYSTEMD_SERVICE = "cliproxy-fleet.service"
31
+ SYSTEMD_SCHEDULED = [
32
+ "cliproxy-monitor.timer",
33
+ "cliproxy-vault-renew.timer",
34
+ "cliproxy-backup.timer",
35
+ "cliproxy-backup-check.timer",
36
+ "cliproxy-gc.timer",
37
+ ]
38
+ LAUNCHD_SERVICE = "dev.devfleet.cliproxy-fleet"
39
+ LAUNCHD_SCHEDULED = [
40
+ "dev.devfleet.cliproxy-monitor",
41
+ "dev.devfleet.cliproxy-vault-renew",
42
+ "dev.devfleet.cliproxy-backup",
43
+ "dev.devfleet.cliproxy-backup-check",
44
+ "dev.devfleet.cliproxy-gc",
45
+ ]
46
+
47
+
48
+ def system(platform=None):
49
+ platform = platform or sys.platform
50
+ if platform == "darwin":
51
+ return "macos"
52
+ if platform.startswith("linux"):
53
+ return "linux"
54
+ raise ValueError("Unsupported proxy host platform")
55
+
56
+
57
+ def default_root(environ=None, platform=None, home=None):
58
+ """CLIPROXY_ROOT wins; otherwise the platform's installation root."""
59
+ environ = os.environ if environ is None else environ
60
+ if environ.get("CLIPROXY_ROOT"):
61
+ return pathlib.Path(environ["CLIPROXY_ROOT"])
62
+ if system(platform) == "macos":
63
+ return pathlib.Path(home or pathlib.Path.home()) / MACOS_ROOT
64
+ return LINUX_ROOT
65
+
66
+
67
+ def runtime_ids(platform=None):
68
+ """Container/state owner: the installing user on macOS, UID/GID 1000 on Linux."""
69
+ if system(platform) == "macos":
70
+ return os.getuid(), os.getgid()
71
+ return LINUX_IDS
72
+
73
+
74
+ def runtime_home(platform=None):
75
+ if system(platform) == "macos":
76
+ return pathlib.Path.home()
77
+ import pwd
78
+
79
+ return pathlib.Path(pwd.getpwuid(LINUX_IDS[0]).pw_dir)
80
+
81
+
82
+ def normalize_node(name):
83
+ name = name.strip().lower()
84
+ name = name.removesuffix(".local")
85
+ return re.sub(r"[^a-z0-9-]", "-", name)
86
+
87
+
88
+ def local_node_id(platform=None, output=subprocess.check_output):
89
+ """macOS LocalHostName (scutil) or the Linux hostname, normalized."""
90
+ if system(platform) == "macos":
91
+ name = output(["scutil", "--get", "LocalHostName"], text=True, timeout=10)
92
+ else:
93
+ name = socket.gethostname()
94
+ return normalize_node(name)
95
+
96
+
97
+ def _executable(path):
98
+ return os.path.isfile(path) and os.access(path, os.X_OK)
99
+
100
+
101
+ def _reachable(path):
102
+ connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
103
+ connection.settimeout(2)
104
+ try:
105
+ connection.connect(path)
106
+ return True
107
+ except OSError:
108
+ return False
109
+ finally:
110
+ connection.close()
111
+
112
+
113
+ class DockerUnavailable(Exception):
114
+ def __init__(self, code, message):
115
+ super().__init__(message)
116
+ self.code = code
117
+
118
+
119
+ def docker_invocation(
120
+ args,
121
+ wrapper,
122
+ platform=None,
123
+ environ=None,
124
+ home=None,
125
+ executable=_executable,
126
+ reachable=_reachable,
127
+ ):
128
+ """Return (argv, environment) for the real Docker CLI behind the tracked wrapper."""
129
+ environ = dict(os.environ if environ is None else environ)
130
+ if system(platform) == "linux":
131
+ if os.access("/var/run/docker.sock", os.R_OK | os.W_OK):
132
+ return ["/usr/bin/docker", *args], environ
133
+ return ["sudo", "-n", "/usr/bin/docker", *args], environ
134
+ home = pathlib.Path(home or pathlib.Path.home())
135
+ own = os.path.realpath(wrapper)
136
+ candidates = [
137
+ os.path.join(directory, "docker")
138
+ for directory in environ.get("PATH", "").split(os.pathsep)
139
+ if directory
140
+ ] + [
141
+ str(home / ".docker/bin/docker"),
142
+ "/usr/local/bin/docker",
143
+ "/opt/homebrew/bin/docker",
144
+ DOCKER_DESKTOP_BIN + "/docker",
145
+ str(home / ".local/share/mise/shims/docker"),
146
+ ]
147
+ binary = next(
148
+ (
149
+ path
150
+ for path in candidates
151
+ if executable(path) and os.path.realpath(path) != own
152
+ ),
153
+ None,
154
+ )
155
+ if binary is None:
156
+ raise DockerUnavailable(
157
+ "DOCKER_CLI_MISSING",
158
+ "DOCKER_CLI_MISSING: install Docker Desktop (it provides the docker CLI).",
159
+ )
160
+ host = environ.get("DOCKER_HOST", "")
161
+ if host and not host.startswith("unix://"):
162
+ socket_path = None # Remote engines are the operator's explicit choice.
163
+ elif host:
164
+ socket_path = host.removeprefix("unix://")
165
+ if not reachable(socket_path):
166
+ socket_path = ""
167
+ else:
168
+ socket_path = next(
169
+ (
170
+ path
171
+ for path in [str(home / ".docker/run/docker.sock"), "/var/run/docker.sock"]
172
+ if reachable(path)
173
+ ),
174
+ "",
175
+ )
176
+ if socket_path and not environ.get("DOCKER_CONTEXT"):
177
+ environ["DOCKER_HOST"] = "unix://" + socket_path
178
+ if socket_path == "":
179
+ raise DockerUnavailable(
180
+ "DOCKER_DESKTOP_NOT_RUNNING",
181
+ "DOCKER_DESKTOP_NOT_RUNNING: start Docker Desktop and enable "
182
+ "'Start Docker Desktop when you sign in'; nothing was changed.",
183
+ )
184
+ # Credential helpers (docker-credential-desktop) live beside the CLI.
185
+ environ["PATH"] = os.pathsep.join(
186
+ [os.path.dirname(binary), DOCKER_DESKTOP_BIN, environ.get("PATH", "")]
187
+ )
188
+ return [binary, *args], environ
189
+
190
+
191
+ class Systemd:
192
+ """Legacy Linux owner backend: fencing/decommission of the previous host."""
193
+
194
+ name = "systemd"
195
+ service = SYSTEMD_SERVICE
196
+ scheduled = SYSTEMD_SCHEDULED
197
+ recovery_gated = set(SYSTEMD_SCHEDULED[2:])
198
+ units = [SYSTEMD_SERVICE, *SYSTEMD_SCHEDULED]
199
+ unit_directory = pathlib.Path("/etc/systemd/system")
200
+ source_directory = "systemd"
201
+
202
+ def state(self, unit, run):
203
+ return (
204
+ run(["systemctl", "is-active", "--quiet", unit]),
205
+ run(["systemctl", "is-enabled", "--quiet", unit]),
206
+ )
207
+
208
+ def enable(self, unit):
209
+ return [["sudo", "-n", "systemctl", "enable", "--now", unit]]
210
+
211
+ def install(self, python, scripts, revision):
212
+ return ["sudo", "-n", python, "-B", str(scripts / "install.py"), "--revision", revision]
213
+
214
+
215
+ class Launchd:
216
+ """Manager owner backend: per-user LaunchAgents in gui/$UID, no root."""
217
+
218
+ name = "launchd"
219
+ service = LAUNCHD_SERVICE
220
+ scheduled = LAUNCHD_SCHEDULED
221
+ recovery_gated = set(LAUNCHD_SCHEDULED[2:])
222
+ # Load scheduled agents first: service-start's audit expects them loaded.
223
+ units = [*LAUNCHD_SCHEDULED, LAUNCHD_SERVICE]
224
+ source_directory = "launchd"
225
+
226
+ def __init__(self, uid=None, home=None):
227
+ self.uid = os.getuid() if uid is None else uid
228
+ self.home = pathlib.Path(home or pathlib.Path.home())
229
+ self.domain = "gui/" + str(self.uid)
230
+ self.unit_directory = self.home / "Library/LaunchAgents"
231
+
232
+ def plist(self, label):
233
+ return self.unit_directory / (label + ".plist")
234
+
235
+ def loaded(self, label, run):
236
+ return run(["launchctl", "print", self.domain + "/" + label])
237
+
238
+ def state(self, unit, run):
239
+ loaded = self.loaded(unit, run)
240
+ return loaded, loaded and self.plist(unit).is_symlink()
241
+
242
+ def enable(self, unit):
243
+ # A prior `launchctl disable` persists; clear it before loading.
244
+ return [
245
+ ["launchctl", "enable", self.domain + "/" + unit],
246
+ ["launchctl", "bootstrap", self.domain, str(self.plist(unit))],
247
+ ]
248
+
249
+ def bootout(self, unit):
250
+ return ["launchctl", "bootout", self.domain + "/" + unit]
251
+
252
+ def install(self, python, scripts, revision):
253
+ return [python, "-B", str(scripts / "install.py"), "--revision", revision]
254
+
255
+
256
+ def backend(platform=None):
257
+ return Launchd() if system(platform) == "macos" else Systemd()
@@ -0,0 +1,150 @@
1
+ """Read-only cloud recovery helpers; no dependency on the running workstation."""
2
+
3
+ import datetime
4
+ import json
5
+ import os
6
+ import pathlib
7
+ import re
8
+ import subprocess
9
+ import urllib.error
10
+ import urllib.request
11
+
12
+ import sys as _sys, pathlib as _p
13
+ _sys.path.insert(0, str(_p.Path(__file__).resolve().parent))
14
+ import fleet_config
15
+
16
+
17
+ def shared_url():
18
+ """The shared Vault document holding the proxy's keys and backup credentials."""
19
+ vault = fleet_config.vault()
20
+ return vault["address"] + "/v1/" + vault["shared"]
21
+
22
+ FIELDS = (
23
+ "S3_ENDPOINT",
24
+ "S3_BUCKET",
25
+ "S3_REGION",
26
+ "S3_ACCESS_KEY_ID",
27
+ "S3_SECRET_ACCESS_KEY",
28
+ "CLIPROXY_FLEET_RESTIC_PASSWORD",
29
+ "CLIPROXY_FLEET_CLIENT_API_KEY",
30
+ "CLIPROXY_FLEET_MANAGEMENT_KEY",
31
+ )
32
+
33
+
34
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
35
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
36
+ return None # Never forward a Vault token or API key to a redirect destination.
37
+
38
+
39
+ def request(url, headers=None):
40
+ if not url.startswith("https://"):
41
+ raise ValueError("HTTPS required")
42
+ headers = {"User-Agent": "cliproxy-fleet/1.0", **(headers or {})}
43
+ try:
44
+ with urllib.request.build_opener(NoRedirect).open(
45
+ urllib.request.Request(url, headers=headers), timeout=30
46
+ ) as response:
47
+ return response.status, response.read()
48
+ except urllib.error.HTTPError as error:
49
+ return error.code, b""
50
+
51
+
52
+ def settings(token):
53
+ status, body = request(
54
+ shared_url(), {"X-Vault-Token": token}
55
+ )
56
+ if status != 200:
57
+ raise ValueError("Vault read failed")
58
+ data = json.loads(body)["data"]["data"]
59
+ if any(
60
+ not isinstance(data.get(name), str) or not data[name].strip() for name in FIELDS
61
+ ):
62
+ raise ValueError("Cloud recovery contract is incomplete")
63
+ return {name: data[name] for name in FIELDS}
64
+
65
+
66
+ def restic_environment(data):
67
+ if not data["S3_ENDPOINT"].startswith("https://"):
68
+ raise ValueError("Storage endpoint must use HTTPS")
69
+ # Do not inherit competing Restic destinations, passwords, AWS profiles or session tokens.
70
+ return {
71
+ "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
72
+ "RESTIC_REPOSITORY": "s3:"
73
+ + data["S3_ENDPOINT"].rstrip("/")
74
+ + "/"
75
+ + data["S3_BUCKET"]
76
+ + "/cliproxy-fleet/restic",
77
+ "RESTIC_PASSWORD": data["CLIPROXY_FLEET_RESTIC_PASSWORD"],
78
+ "AWS_ACCESS_KEY_ID": data["S3_ACCESS_KEY_ID"],
79
+ "AWS_SECRET_ACCESS_KEY": data["S3_SECRET_ACCESS_KEY"],
80
+ "AWS_DEFAULT_REGION": data["S3_REGION"],
81
+ }
82
+
83
+
84
+ def client(data):
85
+ environment = restic_environment(data)
86
+
87
+ def run(*args):
88
+ if not args or args[0] not in ("snapshots", "dump", "restore"):
89
+ raise ValueError("Only read-only cloud operations are permitted")
90
+ return subprocess.run(
91
+ ["restic", "-o", "s3.bucket-lookup=path", "--no-cache", "--no-lock", *args],
92
+ env=environment,
93
+ check=True,
94
+ capture_output=True,
95
+ text=True,
96
+ timeout=180,
97
+ ).stdout
98
+
99
+ return run
100
+
101
+
102
+ def latest(restic):
103
+ snapshots = json.loads(
104
+ restic(
105
+ "snapshots",
106
+ "--json",
107
+ "--host",
108
+ "cliproxy-primary",
109
+ "--tag",
110
+ "cliproxy-checkpoint-v1",
111
+ )
112
+ )
113
+ if not snapshots:
114
+ raise ValueError("No cloud checkpoint")
115
+ snapshot = max(snapshots, key=lambda value: timestamp(value["time"]))
116
+ if not re.fullmatch(r"[0-9a-f]{64}", snapshot["id"]):
117
+ raise ValueError("Invalid checkpoint identifier")
118
+ paths = snapshot["paths"]
119
+ if (
120
+ len(paths) != 1
121
+ or not pathlib.PurePosixPath(paths[0]).is_absolute()
122
+ or ".." in pathlib.PurePosixPath(paths[0]).parts
123
+ ):
124
+ raise ValueError("Invalid checkpoint path")
125
+ return snapshot
126
+
127
+
128
+ def timestamp(value):
129
+ parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
130
+ if parsed.tzinfo is None:
131
+ raise ValueError("Timestamp must include timezone")
132
+ return parsed
133
+
134
+
135
+ def fresh(value, seconds, now=None):
136
+ now = now or datetime.datetime.now(datetime.timezone.utc)
137
+ age = (now - timestamp(value)).total_seconds()
138
+ if not -300 <= age <= seconds:
139
+ raise ValueError("Cloud status is stale or future-dated")
140
+
141
+
142
+ def client_key(token):
143
+ """Read only the client API key; independent of the backup fields in settings()."""
144
+ status, body = request(shared_url(), {"X-Vault-Token": token})
145
+ if status != 200:
146
+ raise ValueError("Vault read failed")
147
+ value = json.loads(body)["data"]["data"].get("CLIPROXY_FLEET_CLIENT_API_KEY")
148
+ if not isinstance(value, str) or not value.strip():
149
+ raise ValueError("Vault has no client API key")
150
+ return value.strip()