@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,527 @@
1
+ #!/usr/bin/env python3
2
+ import sys
3
+
4
+ sys.dont_write_bytecode = True
5
+
6
+ import pathlib as _pathlib
7
+
8
+ sys.path.insert(0, str(_pathlib.Path(__file__).resolve().parent))
9
+ import accounts
10
+ import cliproxy_host
11
+
12
+ import fcntl, json, os, pathlib, shutil, subprocess, sys, tempfile, time, urllib.request, urllib.error
13
+
14
+ root = cliproxy_host.default_root().resolve()
15
+ # tools/bin holds the pinned python3 + PyYAML venv. LaunchAgents already put it
16
+ # first; interactive installs must too, or validate-config gets a system python3.
17
+ os.environ["PATH"] = os.pathsep.join(
18
+ [str(root / "scripts"), str(root / "tools/bin"), os.environ["PATH"]]
19
+ )
20
+ os.umask(0o077)
21
+ os.chdir(root)
22
+
23
+
24
+ def run(args, **kw):
25
+ return subprocess.run(args, check=True, **kw)
26
+
27
+
28
+ def compose(*args, **kw):
29
+ return run(["docker", "compose", *args], **kw)
30
+
31
+
32
+ def validate():
33
+ if (root / "state/recovery-incomplete").exists():
34
+ raise ValueError("Interrupted cache recovery must be resolved before startup")
35
+ run([str(root / "scripts/validate-config")])
36
+ compose("config", "--quiet")
37
+
38
+
39
+ def request(path, key=None, data=None, base="http://127.0.0.1:8317", method=None):
40
+ headers = {}
41
+ if key:
42
+ headers["Authorization"] = "Bearer " + key
43
+ if data is not None:
44
+ headers["Content-Type"] = "application/json"
45
+ req = urllib.request.Request(
46
+ base + path,
47
+ data=json.dumps(data).encode() if data is not None else None,
48
+ headers=headers,
49
+ method=method,
50
+ )
51
+ try:
52
+ with urllib.request.urlopen(req, timeout=30) as r:
53
+ return r.status, r.read()
54
+ except urllib.error.HTTPError as e:
55
+ return e.code, e.read()
56
+
57
+
58
+ def health():
59
+ key = (root / "state/secrets/client-key").read_text().strip()
60
+ assert request("/v1/models")[0] == 401, (
61
+ "Anonymous inference accepted or unavailable"
62
+ )
63
+ code, body = request("/v1/models", key)
64
+ assert code == 200, "Authenticated model listing failed"
65
+ assert request("/v0/management/config", key)[0] in (401, 403), (
66
+ "Client key accessed management"
67
+ )
68
+ admin = (root / "state/secrets/management-key").read_text().strip()
69
+ assert request("/v0/management/config", admin)[0] == 200, (
70
+ "Management authentication failed"
71
+ )
72
+ assert request("/management.html")[0] == 200, "Official dashboard unavailable"
73
+ models = json.loads(body).get("data", [])
74
+ if not isinstance(models, list) or not models:
75
+ raise ValueError("No usable provider models")
76
+ print(
77
+ "PASS: anonymous rejected; client models OK; separate management key enforced; dashboard available."
78
+ )
79
+ print("Available model count:", len(models))
80
+ return models
81
+
82
+
83
+ def wait():
84
+ for _ in range(30):
85
+ try:
86
+ health()
87
+ return
88
+ except Exception:
89
+ time.sleep(2)
90
+ sys.exit(
91
+ "Health did not recover in 60 seconds. Inspect status; use rollback if upgrading."
92
+ )
93
+
94
+
95
+ # Runtime config keys owned by config.template.yaml (everything else in the
96
+ # live config, such as keys and accounts, is runtime state and preserved).
97
+ DECLARED = [("oauth", "model-alias"), ("upstream", "claude")]
98
+
99
+
100
+ def claude_version():
101
+ """`--claude-version X.Y.Z`: the Claude Code version every node runs."""
102
+ import re
103
+
104
+ for index, argument in enumerate(sys.argv):
105
+ if argument == "--claude-version":
106
+ version = sys.argv[index + 1] if index + 1 < len(sys.argv) else ""
107
+ if not re.fullmatch(r"\d+\.\d+\.\d+", version):
108
+ raise ValueError("Invalid Claude Code version")
109
+ return version
110
+ return None
111
+
112
+
113
+ def declared_template():
114
+ """The tracked template; its Claude client version follows the fleet pin."""
115
+ import re, yaml
116
+
117
+ text = (root / "config.template.yaml").read_text()
118
+ version = claude_version()
119
+ if version:
120
+ text = re.sub(r"claude-cli/\d+\.\d+\.\d+", "claude-cli/" + version, text)
121
+ return yaml.safe_load(text)
122
+
123
+
124
+ def declared_candidate():
125
+ """The live config with the declared settings from the template, or None."""
126
+ import yaml
127
+
128
+ template = declared_template()
129
+ live = yaml.safe_load((root / "state/runtime/config.yaml").read_text())
130
+ changed = False
131
+ for section, key in DECLARED:
132
+ want = (template.get(section) or {}).get(key)
133
+ have = (live.get(section) or {}).get(key)
134
+ if want == have:
135
+ continue
136
+ changed = True
137
+ live.setdefault(section, {})
138
+ if want is None:
139
+ live[section].pop(key, None)
140
+ else:
141
+ live[section][key] = want
142
+ return yaml.safe_dump(live, sort_keys=False) if changed else None
143
+
144
+
145
+ def apply_config(candidate, restart=True):
146
+ run([str(root / "scripts/validate-config"), str(candidate), "--require-fill-first"])
147
+ staging(candidate)
148
+ shutil.copy2(
149
+ root / "state/runtime/config.yaml",
150
+ root / "state/runtime/config.yaml.previous",
151
+ )
152
+ shutil.copyfile(candidate, root / "state/runtime/config.yaml.new")
153
+ os.chmod(root / "state/runtime/config.yaml.new", 0o600)
154
+ os.replace(
155
+ root / "state/runtime/config.yaml.new", root / "state/runtime/config.yaml"
156
+ )
157
+ if restart:
158
+ compose("restart", "proxy")
159
+ wait()
160
+
161
+
162
+ def declared_aliases():
163
+ """Client-visible model ids the declared aliases must add."""
164
+ import yaml
165
+
166
+ template = yaml.safe_load((root / "config.template.yaml").read_text())
167
+ aliases = ((template.get("oauth") or {}).get("model-alias") or {}).values()
168
+ return {entry["alias"] for entries in aliases for entry in entries or []}
169
+
170
+
171
+ def hot_reloaded(expected):
172
+ """The proxy watches its config file; wait for the reload to serve it."""
173
+ for _ in range(15):
174
+ try:
175
+ served = {model.get("id") for model in health()}
176
+ if expected <= served:
177
+ return True
178
+ except Exception:
179
+ pass
180
+ time.sleep(2)
181
+ return False
182
+
183
+
184
+ def pending_prefixes():
185
+ """Credentials that lack their account's model prefix."""
186
+ return accounts.pending(root / "state/runtime/auths")
187
+
188
+
189
+ def apply_prefixes(prefixes):
190
+ # The proxy owns its credential files (it rewrites them on token refresh),
191
+ # so the prefix is set through its own API, never by editing a file.
192
+ admin = (root / "state/secrets/management-key").read_text().strip()
193
+ for name, prefix in prefixes:
194
+ code, _ = request(
195
+ "/v0/management/auth-files/fields",
196
+ admin,
197
+ {"name": name, "prefix": prefix},
198
+ method="PATCH",
199
+ )
200
+ assert code == 200, "Credential prefix update failed"
201
+ for _ in range(15):
202
+ if not pending_prefixes():
203
+ return
204
+ time.sleep(2)
205
+ sys.exit("The proxy did not record the declared account prefixes.")
206
+
207
+
208
+ def lock():
209
+ f = open(root / "state/status/operation.lock", "w")
210
+ if os.geteuid() == 0:
211
+ os.chown(root / "state/status/operation.lock", 1000, 1000)
212
+ fcntl.flock(f, fcntl.LOCK_EX)
213
+ return f
214
+
215
+
216
+ def staging(config):
217
+ # No provider credential files are mounted; isolated port and temporary state.
218
+ import yaml
219
+
220
+ with tempfile.TemporaryDirectory(prefix="cliproxy-stage-") as d:
221
+ stage = pathlib.Path(d)
222
+ stage.chmod(0o755)
223
+ cfg = yaml.safe_load(pathlib.Path(config).read_text())
224
+ cfg["server"]["port"] = 8317
225
+ cfg["oauth"] = {"auth-dir": "/stage/auths"}
226
+ cfg["management"]["disable-control-panel"] = True
227
+ for field in [
228
+ "api-keys",
229
+ "gemini-api-key",
230
+ "claude-api-key",
231
+ "codex-api-key",
232
+ "openai-compatibility",
233
+ "xai-api-key",
234
+ "meta-api-key",
235
+ ]:
236
+ cfg.pop(field, None)
237
+ cfg["management"]["disable-auto-update-panel"] = True
238
+ (stage / "auths").mkdir(mode=0o700)
239
+ (stage / "config.yaml").write_text(yaml.safe_dump(cfg))
240
+ (stage / "config.yaml").chmod(0o600)
241
+ image = dict(
242
+ line.split("=", 1) for line in (root / ".env").read_text().splitlines()
243
+ )["CLI_PROXY_IMAGE"]
244
+ name = "cliproxy-validate-" + str(os.getpid())
245
+ try:
246
+ run(
247
+ [
248
+ "docker",
249
+ "run",
250
+ "-d",
251
+ "--name",
252
+ name,
253
+ "--user",
254
+ "%d:%d" % cliproxy_host.runtime_ids(),
255
+ "--cap-drop=ALL",
256
+ "--security-opt=no-new-privileges",
257
+ "--memory=512m",
258
+ "--pids-limit=128",
259
+ "-v",
260
+ str(stage) + ":/stage",
261
+ "-p",
262
+ "127.0.0.1::8317",
263
+ image,
264
+ "/CLIProxyAPI/CLIProxyAPI",
265
+ "-config",
266
+ "/stage/config.yaml",
267
+ ],
268
+ stdout=subprocess.DEVNULL,
269
+ )
270
+ mapping = subprocess.check_output(
271
+ ["docker", "port", name, "8317/tcp"], text=True
272
+ ).strip()
273
+ for _ in range(20):
274
+ try:
275
+ assert request("/v1/models", base="http://" + mapping)[0] == 401
276
+ key = cfg["access"]["api-keys"][0]
277
+ assert (
278
+ request("/v1/models", key, base="http://" + mapping)[0] == 200
279
+ )
280
+ print("Isolated runtime validation passed.")
281
+ return
282
+ except Exception:
283
+ time.sleep(1)
284
+ raise RuntimeError("staging failed")
285
+ finally:
286
+ run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL)
287
+
288
+
289
+ command = sys.argv[1] if len(sys.argv) > 1 else "status"
290
+ try:
291
+ if command == "validate":
292
+ validate()
293
+ staging(root / "state/runtime/config.yaml")
294
+ elif command == "start":
295
+ operation_lock = lock()
296
+ validate()
297
+ compose("up", "-d", "--wait")
298
+ health()
299
+ if (root / "state/endpoint.json").exists():
300
+ compose("--profile", "portable-endpoint", "up", "-d", "tailnet")
301
+ if (root / "state/public-endpoint-enabled").exists():
302
+ run([str(root / "scripts/public-endpoint"), "start"])
303
+ elif command == "stop":
304
+ operation_lock = lock()
305
+ compose(
306
+ "--profile", "portable-endpoint", "--profile", "public-endpoint", "stop"
307
+ )
308
+ elif command == "restart":
309
+ operation_lock = lock()
310
+ validate()
311
+ compose("restart", "proxy")
312
+ wait()
313
+ elif command == "health":
314
+ health()
315
+ elif command == "status":
316
+ compose("--profile", "portable-endpoint", "--profile", "public-endpoint", "ps")
317
+ health()
318
+ for f in [
319
+ "backup-success.json",
320
+ "backup-failed",
321
+ "backup-check-failed",
322
+ "backup-operation-failed",
323
+ "monitor-failed",
324
+ "gc-failed",
325
+ "gc-success.json",
326
+ ]:
327
+ p = root / "state/status" / f
328
+ if p.exists():
329
+ print(f, p.read_text().strip())
330
+ elif command == "models":
331
+ for m in health():
332
+ print(m["id"])
333
+ elif command == "logs":
334
+ # Avoid emitting unreviewed upstream logs: redact every known credential, then token patterns.
335
+ import re, yaml
336
+
337
+ data = subprocess.check_output(
338
+ ["docker", "compose", "logs", "--no-color", "--tail", "100", "proxy"],
339
+ stderr=subprocess.STDOUT,
340
+ text=True,
341
+ )
342
+ config = yaml.safe_load((root / "state/runtime/config.yaml").read_text())
343
+ values = []
344
+
345
+ def visit(obj):
346
+ if isinstance(obj, dict):
347
+ for k, v in obj.items():
348
+ if isinstance(v, str) and any(
349
+ x in k.lower() for x in ["key", "token", "secret", "password"]
350
+ ):
351
+ values.append(v)
352
+ visit(v)
353
+ elif isinstance(obj, list):
354
+ for v in obj:
355
+ visit(v)
356
+
357
+ visit(config)
358
+ values.extend(config["access"]["api-keys"])
359
+ for f in (root / "state/secrets").iterdir():
360
+ if f.is_file() and f.stat().st_size < 10000:
361
+ try:
362
+ text = f.read_text().strip()
363
+ if f.suffix == ".json":
364
+ visit(json.loads(text))
365
+ else:
366
+ values.append(text)
367
+ except Exception:
368
+ pass
369
+ for f in (root / "state/runtime/auths").glob("*.json"):
370
+ try:
371
+ visit(json.loads(f.read_text()))
372
+ except Exception:
373
+ pass
374
+ for value in sorted(set(values), key=len, reverse=True):
375
+ if len(value) >= 6:
376
+ data = data.replace(value, "[REDACTED]")
377
+ data = re.sub(r'(?i)(bearer\s+)[^\s"\']+', r"\1[REDACTED]", data)
378
+ data = re.sub(r"(?:sk-|eyJ)[A-Za-z0-9_.-]{15,}", "[REDACTED]", data)
379
+ print(data)
380
+ elif command == "apply":
381
+ operation_lock = lock()
382
+ apply_config(pathlib.Path(sys.argv[2]).resolve())
383
+ elif command == "declared":
384
+ # Fleet-owned settings: template config keys and one model prefix per
385
+ # signed-in account. Check exits 3 when the live proxy differs;
386
+ # --apply replaces only those settings.
387
+ candidate = declared_candidate()
388
+ prefixes = pending_prefixes()
389
+ if candidate is None and not prefixes:
390
+ print("Declared settings match the live config.")
391
+ elif "--apply" not in sys.argv[2:]:
392
+ print("Declared settings differ from the live config.")
393
+ sys.exit(3)
394
+ else:
395
+ operation_lock = lock()
396
+ if candidate is not None:
397
+ with tempfile.NamedTemporaryFile(
398
+ "w", dir=root / "state/runtime", suffix=".yaml", delete=False
399
+ ) as file:
400
+ file.write(candidate)
401
+ try:
402
+ os.chmod(file.name, 0o600)
403
+ # Restart-free: the proxy hot-reloads config.yaml. Restart
404
+ # only if the reload never serves the declared settings.
405
+ apply_config(pathlib.Path(file.name), restart=False)
406
+ if not hot_reloaded(declared_aliases()):
407
+ compose("restart", "proxy")
408
+ wait()
409
+ finally:
410
+ pathlib.Path(file.name).unlink(missing_ok=True)
411
+ if prefixes:
412
+ apply_prefixes(prefixes)
413
+ elif command in ["upgrade", "rollback"]:
414
+ sys.exit(
415
+ "Install a reviewed Git revision with bun run cliproxy install --revision REV; use --rollback for the recorded previous revision."
416
+ )
417
+ elif command == "add-account":
418
+ operation_lock = lock()
419
+ provider = sys.argv[2]
420
+ flags = {
421
+ "codex": "-codex-device-login",
422
+ "claude": "-claude-login",
423
+ "gemini": "-login",
424
+ "antigravity": "-antigravity-login",
425
+ "kimi": "-kimi-login",
426
+ "xai": "-xai-login",
427
+ "meta": "-meta-login",
428
+ "devin": "-devin-login",
429
+ }
430
+ if provider == "gemini":
431
+ sys.exit(
432
+ "This release has no Gemini CLI login flag. Use official dashboard OAuth/API key configuration."
433
+ )
434
+ flag = flags[provider]
435
+ compose(
436
+ "exec",
437
+ "proxy",
438
+ "/CLIProxyAPI/CLIProxyAPI",
439
+ "-config",
440
+ "/state/config.yaml",
441
+ flag,
442
+ "-no-browser",
443
+ )
444
+ elif command == "inference":
445
+ protocol, model = sys.argv[2:4]
446
+ key = (root / "state/secrets/client-key").read_text().strip()
447
+ if protocol == "responses":
448
+ path = "/v1/responses"
449
+ body = {
450
+ "model": model,
451
+ "input": "Reply only OK",
452
+ "stream": True,
453
+ "max_output_tokens": 32,
454
+ }
455
+ elif protocol == "messages":
456
+ path = "/v1/messages"
457
+ body = {
458
+ "model": model,
459
+ "messages": [{"role": "user", "content": "Reply only OK"}],
460
+ "stream": True,
461
+ "max_tokens": 16,
462
+ }
463
+ else:
464
+ path = "/v1/chat/completions"
465
+ body = {
466
+ "model": model,
467
+ "messages": [{"role": "user", "content": "Reply only OK"}],
468
+ "stream": True,
469
+ "max_tokens": 16,
470
+ }
471
+ code, data = request(path, key, body)
472
+ assert code == 200 and b"data:" in data, (
473
+ "Streaming inference failed (response suppressed)"
474
+ )
475
+ print(
476
+ "Streaming transport returned SSE. Check protocol completion and tools with the actual client before production use."
477
+ )
478
+ elif command == "portable-login":
479
+ compose("--profile", "portable-endpoint", "up", "-d", "tailnet")
480
+ compose(
481
+ "exec",
482
+ "tailnet",
483
+ "tailscale",
484
+ "--socket=/tmp/tailscaled.sock",
485
+ "up",
486
+ "--hostname=cliproxy-fleet",
487
+ "--advertise-tags=tag:ai-proxy",
488
+ "--accept-dns=false",
489
+ )
490
+ elif command == "activate":
491
+ health()
492
+ compose(
493
+ "exec",
494
+ "tailnet",
495
+ "tailscale",
496
+ "--socket=/tmp/tailscaled.sock",
497
+ "serve",
498
+ "--service=svc:ai-proxy",
499
+ "--https=443",
500
+ "http://proxy:8317",
501
+ )
502
+ compose(
503
+ "exec",
504
+ "tailnet",
505
+ "tailscale",
506
+ "--socket=/tmp/tailscaled.sock",
507
+ "serve",
508
+ "status",
509
+ )
510
+ elif command == "drain":
511
+ compose(
512
+ "exec",
513
+ "tailnet",
514
+ "tailscale",
515
+ "--socket=/tmp/tailscaled.sock",
516
+ "serve",
517
+ "drain",
518
+ "svc:ai-proxy",
519
+ )
520
+ else:
521
+ sys.exit(
522
+ "Commands: start stop restart status health models logs validate apply FILE declared [--apply] add-account PROVIDER inference PROTOCOL MODEL portable-login activate drain"
523
+ )
524
+ except Exception:
525
+ sys.exit(
526
+ "Operation failed; secrets and upstream error responses suppressed. Check sanitized logs and service status."
527
+ )
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env python3
2
+ """Run the OpenTofu-owned public connector; never provision cloud resources."""
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
+ import fleet_config
13
+
14
+ import importlib.util
15
+ import json
16
+ import os
17
+ import pathlib
18
+ import subprocess
19
+ import urllib.error
20
+ import urllib.request
21
+
22
+
23
+ def save_token(path, value):
24
+ """Atomically refreshes the credential; returns whether it changed."""
25
+ if not isinstance(value, str) or not value.strip() or "\n" in value.strip():
26
+ raise ValueError("Missing or invalid connector credential")
27
+ try:
28
+ if path.read_text() == value.strip() + "\n":
29
+ return False
30
+ except OSError:
31
+ pass
32
+ temporary = path.with_name(path.name + ".new")
33
+ descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
34
+ with os.fdopen(descriptor, "w") as file:
35
+ file.write(value.strip() + "\n")
36
+ temporary.chmod(0o600)
37
+ if os.geteuid() == 0:
38
+ os.chown(temporary, 1000, 1000)
39
+ os.replace(temporary, path)
40
+ return True
41
+
42
+
43
+ def connector_running(compose):
44
+ result = subprocess.run(
45
+ compose + ["ps", "--status", "running", "--quiet", "cloudflared"],
46
+ capture_output=True,
47
+ text=True,
48
+ )
49
+ return result.returncode == 0 and bool(result.stdout.strip())
50
+
51
+
52
+ def verify(root, endpoint):
53
+ def request(path, key=None):
54
+ headers = {"User-Agent": "cliproxy-fleet/1.0"}
55
+ if key:
56
+ headers["Authorization"] = "Bearer " + key
57
+ try:
58
+ with urllib.request.urlopen(
59
+ urllib.request.Request(endpoint + path, headers=headers), timeout=30
60
+ ) as response:
61
+ return response.status, response.read()
62
+ except urllib.error.HTTPError as error:
63
+ return error.code, b""
64
+
65
+ client = (root / "state/secrets/client-key").read_text().strip()
66
+ admin = (root / "state/secrets/management-key").read_text().strip()
67
+ assert request("/v1/models")[0] == 401
68
+ status, body = request("/v1/models", client)
69
+ assert status == 200 and json.loads(body).get("data")
70
+ assert request("/v0/management/config")[0] in (401, 403)
71
+ assert request("/v0/management/config", client)[0] in (401, 403)
72
+ assert request("/v0/management/config", admin)[0] == 200
73
+ assert request("/management.html")[0] == 200
74
+ print(
75
+ "PASS: public HTTPS, authenticated models, separate management key and dashboard."
76
+ )
77
+
78
+
79
+ def main():
80
+ os.umask(0o077)
81
+ root = cliproxy_host.default_root()
82
+ command = sys.argv[1] if len(sys.argv) == 2 else "verify"
83
+ if command not in ["enable", "start", "disable", "verify"]:
84
+ raise ValueError("Use enable, start, disable or verify")
85
+ marker = root / "state/public-endpoint-enabled"
86
+ if command == "verify":
87
+ verify(root, fleet_config.endpoint())
88
+ return
89
+ compose = [str(root / "scripts/docker"), "compose", "--profile", "public-endpoint"]
90
+ if command == "disable":
91
+ subprocess.run(compose + ["stop", "cloudflared"], check=True)
92
+ marker.unlink(missing_ok=True)
93
+ return
94
+ spec = importlib.util.spec_from_file_location(
95
+ "vault_access", root / "scripts/vault-access.py"
96
+ )
97
+ vault = importlib.util.module_from_spec(spec)
98
+ spec.loader.exec_module(vault)
99
+ vault.renew()
100
+ location = fleet_config.vault()
101
+ value = vault.request(location["tunnel"], vault.token())["data"]["data"][
102
+ location["tunnel_field"]
103
+ ]
104
+ changed = save_token(root / "state/secrets/tunnel-token", value)
105
+ # Recreate only when the bind mount must see a refreshed credential (or the
106
+ # connector is down): a needless recreate drops the public endpoint on
107
+ # every proxy start or update.
108
+ if changed or not connector_running(compose):
109
+ subprocess.run(
110
+ compose + ["up", "-d", "--force-recreate", "cloudflared"], check=True
111
+ )
112
+ if command == "enable":
113
+ marker.touch(mode=0o600)
114
+ if os.geteuid() == 0:
115
+ os.chown(marker, 1000, 1000)
116
+ print(
117
+ "Public connector running at " + fleet_config.endpoint() + "; credential withheld."
118
+ )
119
+
120
+
121
+ if __name__ == "__main__":
122
+ try:
123
+ main()
124
+ except Exception:
125
+ raise SystemExit(
126
+ "Public endpoint operation failed; check Vault access, deployment and DNS. Secret values suppressed."
127
+ ) from None