@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,123 @@
1
+ import { mkdir, rm } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { FleetUsageError } from '../errors';
4
+ import { canonical, object, read, safePath, write } from './files';
5
+ import { ownership, planT3, readOwnership, type T3Paths } from './plan';
6
+
7
+ export async function reconcileT3(
8
+ paths: T3Paths,
9
+ desired: Record<string, unknown>,
10
+ options: { apply: boolean; planHash?: string }
11
+ ) {
12
+ if (!options.apply) return report(await planT3(paths, desired), false);
13
+ return locked(paths, async () => {
14
+ const plan = await planT3(paths, desired);
15
+ if (options.planHash && options.planHash !== plan.planHash)
16
+ throw new FleetUsageError(
17
+ 'T3 plan is stale. Run devfleet t3-apply --dry-run again.'
18
+ );
19
+ if (plan.changes.some((change) => change.action === 'conflict'))
20
+ throw new FleetUsageError(
21
+ 'T3 instance already exists without ownership. Review it and use devfleet t3-apply --import ID.'
22
+ );
23
+ if (!plan.settingsChanged && !plan.stateChanged) return report(plan, true);
24
+ await persist(paths, desired, plan);
25
+ const verified = await planT3(paths, desired);
26
+ if (verified.settingsChanged || verified.stateChanged)
27
+ throw new FleetUsageError(
28
+ 'T3 changed after apply; reconciliation is incomplete. Replan and retry.'
29
+ );
30
+ return report(plan, true);
31
+ });
32
+ }
33
+
34
+ export async function importT3(paths: T3Paths, id: string) {
35
+ return locked(paths, async () => {
36
+ const raw = await read(paths.settings);
37
+ const instances = object(
38
+ JSON.stringify(object(raw)['providerInstances'] ?? {})
39
+ );
40
+ if (!Object.hasOwn(instances, id))
41
+ throw new FleetUsageError('T3 import requires an existing instance ID.');
42
+ const state = await read(paths.state);
43
+ const owned = readOwnership(state, paths.settings);
44
+ if (!owned.includes(id)) {
45
+ if (state !== null) await write(`${paths.state}.backup`, state);
46
+ if ((await read(paths.settings)) !== raw)
47
+ throw new FleetUsageError('T3 changed during import; retry.');
48
+ await write(
49
+ paths.state,
50
+ canonical(ownership(paths.settings, [...owned, id])) + '\n'
51
+ );
52
+ }
53
+ return { version: 1, ok: true, imported: id, changed: !owned.includes(id) };
54
+ });
55
+ }
56
+
57
+ async function locked<T>(paths: T3Paths, action: () => Promise<T>): Promise<T> {
58
+ await safePath(paths.state);
59
+ await mkdir(dirname(paths.state), { recursive: true, mode: 0o700 });
60
+ const lock = `${paths.state}.lock`;
61
+ try {
62
+ await mkdir(lock, { mode: 0o700 });
63
+ } catch {
64
+ throw new FleetUsageError(
65
+ 'Another personal T3 operation holds the lock. Inspect interrupted operations before removing a stale lock.'
66
+ );
67
+ }
68
+ try {
69
+ return await action();
70
+ } finally {
71
+ await rm(lock, { recursive: true });
72
+ }
73
+ }
74
+
75
+ function report(plan: Awaited<ReturnType<typeof planT3>>, applied: boolean) {
76
+ return {
77
+ version: 1,
78
+ ok: !plan.changes.some((change) => change.action === 'conflict'),
79
+ applied,
80
+ changed: plan.settingsChanged || plan.stateChanged,
81
+ planHash: plan.planHash,
82
+ changes: plan.changes,
83
+ runtime:
84
+ 'T3 watches settings.json; new sessions use the reconciled instances. Active sessions are not restarted.',
85
+ };
86
+ }
87
+
88
+ async function persist(
89
+ paths: T3Paths,
90
+ desired: Record<string, unknown>,
91
+ plan: Awaited<ReturnType<typeof planT3>>
92
+ ) {
93
+ // Keep a private recovery copy. Contents, environment and secret references never enter reports.
94
+ if (plan.raw !== null && plan.settingsChanged)
95
+ await write(`${paths.state}.settings-backup`, plan.raw);
96
+ if (plan.stateRaw !== null)
97
+ await write(`${paths.state}.backup`, plan.stateRaw);
98
+ if (
99
+ (await read(paths.settings)) !== plan.raw ||
100
+ (await read(paths.state)) !== plan.stateRaw
101
+ )
102
+ throw new FleetUsageError(
103
+ 'T3 configuration changed during apply. Replan and retry.'
104
+ );
105
+ // Intent first: retry after interruption can finish creates/deletes without importing them.
106
+ const previous = readOwnership(plan.stateRaw, paths.settings);
107
+ await write(
108
+ paths.state,
109
+ canonical(
110
+ ownership(paths.settings, [
111
+ ...new Set([...previous, ...Object.keys(desired)]),
112
+ ])
113
+ ) + '\n'
114
+ );
115
+ if (plan.settingsChanged) {
116
+ if ((await read(paths.settings)) !== plan.raw)
117
+ throw new FleetUsageError(
118
+ 'T3 changed during apply. Ownership intent was saved; replan and retry.'
119
+ );
120
+ await write(paths.settings, JSON.stringify(plan.settings, null, 2) + '\n');
121
+ }
122
+ await write(paths.state, canonical(plan.state) + '\n');
123
+ }
@@ -0,0 +1,48 @@
1
+ import { discoverTailnet } from './tailscale';
2
+ import type { Check, FleetNode, Runner } from './types';
3
+
4
+ /** A fresh manager HTTPS request is transport evidence only. Never read
5
+ * pairing material or treat HTTP health as an authenticated tool session.
6
+ */
7
+ export async function verifyT3Endpoint(
8
+ node: FleetNode,
9
+ run: Runner,
10
+ request: (url: string, options: RequestInit) => Promise<Response> = fetch
11
+ ): Promise<Check[]> {
12
+ const tailnet = await discoverTailnet(run);
13
+ const peer = tailnet.computers.find((entry) => entry.id === node.tailscaleId);
14
+ if (!peer || !/^[a-z0-9.-]+\.ts\.net$/.test(peer.dns))
15
+ return pending('T3_MANAGER_IDENTITY_UNVERIFIED');
16
+ try {
17
+ const response = await request('https://' + peer.dns, {
18
+ method: 'GET',
19
+ redirect: 'manual',
20
+ signal: AbortSignal.timeout(10_000),
21
+ });
22
+ await response.body?.cancel();
23
+ if (response.status !== 200) return pending('T3_MANAGER_ENDPOINT_REJECTED');
24
+ return [
25
+ {
26
+ id: 't3:manager-endpoint',
27
+ status: 'pass',
28
+ summary:
29
+ 'A fresh HTTPS request from the manager reached the enrolled private endpoint with valid TLS and HTTP 200. Pairing, server compatibility and tool execution remain unverified.',
30
+ },
31
+ ];
32
+ } catch {
33
+ return pending('T3_MANAGER_ENDPOINT_UNREACHABLE');
34
+ }
35
+ }
36
+ function pending(code: string): Check[] {
37
+ return [
38
+ {
39
+ id: 't3:manager-endpoint',
40
+ status: 'fail',
41
+ summary: code,
42
+ remedy: {
43
+ action:
44
+ 'Inspect the enrolled node’s private Serve route, T3 listener, Tailscale reachability and HTTPS capability. Restore the existing private route in source; never bypass TLS verification or interpret a local listener as manager access.',
45
+ },
46
+ },
47
+ ];
48
+ }
@@ -0,0 +1,101 @@
1
+ import { sshArgs } from './ssh';
2
+ import { record } from './config';
3
+ import type { Check, FleetNode, Runner } from './types';
4
+
5
+ /** Correlate a live SSH-verified server identity with the native manager's authenticated link. */
6
+ export async function verifyT3Linked(
7
+ node: FleetNode,
8
+ run: Runner,
9
+ options: {
10
+ now?: () => number;
11
+ /** After this sync restarted the node's T3, give its link time to reconnect. */
12
+ reconnectMs?: number;
13
+ pause?: () => Promise<void>;
14
+ } = {}
15
+ ): Promise<Check> {
16
+ const now = options.now ?? Date.now;
17
+ const deadline = now() + (options.reconnectMs ?? 0);
18
+ let check = await linkedOnce(node, run, now());
19
+ while (check.status === 'fail' && now() < deadline) {
20
+ await (
21
+ options.pause ?? (() => new Promise((done) => setTimeout(done, 10_000)))
22
+ )();
23
+ check = await linkedOnce(node, run, now());
24
+ }
25
+ return check;
26
+ }
27
+
28
+ async function linkedOnce(
29
+ node: FleetNode,
30
+ run: Runner,
31
+ now: number
32
+ ): Promise<Check> {
33
+ const unavailable: Check = {
34
+ id: 't3:paired-access',
35
+ status: 'fail',
36
+ summary: 'MANAGER_T3_LINK_UNVERIFIED',
37
+ remedy: {
38
+ action:
39
+ 'Link this exact worker environment to the manager using T3 Connections once. Existing pairing identities are preserved; passwords and pairing tokens are never copied by Fleet.',
40
+ },
41
+ };
42
+ try {
43
+ const identity =
44
+ node.t3EnvironmentId ?? (await observedIdentity(node, run));
45
+ if (!identity) return unavailable;
46
+ const result = await run(['t3', 'environment', 'list', '--json'], {
47
+ timeoutMs: 30_000,
48
+ });
49
+ const links: unknown = JSON.parse(result.stdout);
50
+ if (!result.ok || !Array.isArray(links)) return unavailable;
51
+ return linkedEvidence(links, identity, unavailable, now);
52
+ } catch {
53
+ return unavailable;
54
+ }
55
+ }
56
+
57
+ function linkedEvidence(
58
+ links: unknown[],
59
+ environmentId: string,
60
+ unavailable: Check,
61
+ now: number
62
+ ): Check {
63
+ const link = links.find(
64
+ (value) => record(value) && value['environmentId'] === environmentId
65
+ );
66
+ if (
67
+ !record(link) ||
68
+ link['status'] !== 'reachable' ||
69
+ typeof link['expiresAt'] !== 'string'
70
+ )
71
+ return unavailable;
72
+ const expires = Date.parse(link['expiresAt']);
73
+ if (!Number.isFinite(expires) || expires <= now) return unavailable;
74
+ return {
75
+ ...unavailable,
76
+ status: expires - now < 7 * 86400000 ? 'warn' : 'pass',
77
+ summary:
78
+ expires - now < 7 * 86400000
79
+ ? 'Exact worker link is reachable but its manager authorization expires within seven days.'
80
+ : 'Native T3 authenticated link reaches the exact enrolled worker environment. This checks connected access, not a model task.',
81
+ };
82
+ }
83
+
84
+ async function observedIdentity(
85
+ node: FleetNode,
86
+ run: Runner
87
+ ): Promise<string | undefined> {
88
+ const server = await run(
89
+ [
90
+ ...sshArgs(node),
91
+ 'curl --fail --silent --max-time 5 http://127.0.0.1:3773/.well-known/t3/environment',
92
+ ],
93
+ { timeoutMs: 15_000 }
94
+ );
95
+ const identity: unknown = JSON.parse(server.stdout);
96
+ return server.ok &&
97
+ record(identity) &&
98
+ typeof identity['environmentId'] === 'string'
99
+ ? identity['environmentId']
100
+ : undefined;
101
+ }
@@ -0,0 +1,231 @@
1
+ import { compareFingerprints } from './fingerprint';
2
+ import { probe } from './probe';
3
+ import { inspectRemote } from './transport';
4
+ import { mapBounded } from './report';
5
+ import { matchComputers } from './tailnet-matching';
6
+ import type { Tailnet, TailnetComputer } from './tailscale';
7
+ import type {
8
+ Capability,
9
+ Fingerprint,
10
+ Check,
11
+ FleetNode,
12
+ NodeReport,
13
+ Runner,
14
+ Snapshot,
15
+ } from './types';
16
+
17
+ export const HEALTH_CAPABILITIES: Capability[] = [
18
+ 't3',
19
+ 'toolchain',
20
+ 'workstation',
21
+ ];
22
+ export interface TailnetHealthOptions {
23
+ includeManagerReport?: boolean;
24
+ reference?: Snapshot | undefined;
25
+ root: string;
26
+ timeoutMs: number;
27
+ source: () => Promise<string>;
28
+ }
29
+
30
+ export async function tailnetHealth(
31
+ tailnet: Tailnet,
32
+ nodes: FleetNode[],
33
+ options: TailnetHealthOptions,
34
+ run: Runner
35
+ ): Promise<NodeReport[]> {
36
+ const matches = await matchComputers(nodes, tailnet.computers, run);
37
+ const self = tailnet.computers.find((peer) => peer.self);
38
+ const candidates = self ? (matches.get(self.id) ?? []) : [];
39
+ const required = uniqueMapping(candidates, matches)
40
+ ? candidates[0]!.required
41
+ : HEALTH_CAPABILITIES;
42
+ const manager =
43
+ options.reference ??
44
+ (await probe(
45
+ options.root,
46
+ options.includeManagerReport === false && !self ? [] : required,
47
+ run
48
+ ));
49
+ let source: Promise<string> | undefined;
50
+ const reports = await mapBounded(tailnet.computers, async (peer) => {
51
+ const checks = await connectivity(peer, run);
52
+ if (peer.self)
53
+ return {
54
+ node: peer.dns,
55
+ snapshot: manager,
56
+ checks: [...checks, ...manager.checks],
57
+ };
58
+ const candidates = matches.get(peer.id)!;
59
+ if (!uniqueMapping(candidates, matches))
60
+ return missingMapping(peer, checks, candidates.length);
61
+ if (!peer.online) return { node: peer.dns, checks };
62
+ const node = candidates[0]!;
63
+ const report = await inspectComputer(
64
+ node,
65
+ {
66
+ action: 'probe',
67
+ expected: manager.fingerprint,
68
+ source: (source ??= Promise.resolve().then(options.source)),
69
+ timeoutMs: options.timeoutMs,
70
+ },
71
+ run
72
+ );
73
+ return combine(peer, checks, report, manager);
74
+ });
75
+ const absent = nodes.filter(
76
+ (node) => ![...matches.values()].some((matched) => matched.includes(node))
77
+ );
78
+ return [
79
+ { node: 'tailscale', checks: tailnet.checks },
80
+ ...(options.includeManagerReport !== false &&
81
+ !tailnet.computers.some((peer) => peer.self)
82
+ ? [
83
+ {
84
+ node: 'manager',
85
+ snapshot: manager,
86
+ checks: manager.checks,
87
+ },
88
+ ]
89
+ : []),
90
+ ...reports,
91
+ ...absent.map(absentNode),
92
+ ];
93
+ }
94
+
95
+ export function uniqueMapping(
96
+ candidates: FleetNode[],
97
+ matches: Map<string, FleetNode[]>
98
+ ): boolean {
99
+ return (
100
+ candidates.length === 1 &&
101
+ [...matches.values()].filter((nodes) => nodes.includes(candidates[0]!))
102
+ .length === 1
103
+ );
104
+ }
105
+
106
+ async function inspectComputer(
107
+ node: FleetNode,
108
+ options: {
109
+ action: 'probe';
110
+ source: Promise<string>;
111
+ timeoutMs: number;
112
+ expected?: Fingerprint;
113
+ },
114
+ run: Runner
115
+ ): Promise<NodeReport> {
116
+ try {
117
+ return await inspectRemote(
118
+ node,
119
+ { ...options, source: await options.source },
120
+ run
121
+ );
122
+ } catch {
123
+ return {
124
+ node: node.id,
125
+ checks: [
126
+ {
127
+ id: 'fleet:probe',
128
+ status: 'fail',
129
+ summary:
130
+ 'The current remote probe could not be prepared or completed; this computer remains unverified.',
131
+ remedy: {
132
+ action:
133
+ 'Reinstall the validated Personal CLI package and retry. No raw exception or remote output is retained.',
134
+ },
135
+ },
136
+ ],
137
+ };
138
+ }
139
+ }
140
+
141
+ async function connectivity(
142
+ peer: TailnetComputer,
143
+ run: Runner
144
+ ): Promise<Check[]> {
145
+ const online: Check = {
146
+ id: 'tailscale:online',
147
+ status: peer.online ? 'pass' : 'fail',
148
+ summary: peer.online
149
+ ? 'Computer is online in Tailscale status.'
150
+ : 'Computer is offline or Tailscale cannot confirm its online state.',
151
+ };
152
+ if (peer.self || !peer.online) return [online];
153
+ const ping = await run(
154
+ ['tailscale', 'ping', '--c=1', '--timeout=5s', peer.ips[0]!],
155
+ { timeoutMs: 8_000 }
156
+ );
157
+ return [
158
+ online,
159
+ {
160
+ id: 'tailscale:reachability',
161
+ status: ping.ok ? 'pass' : 'fail',
162
+ summary: ping.ok
163
+ ? 'Tailscale peer answered a bounded reachability probe.'
164
+ : 'Tailscale peer did not answer; online inventory is not proof of connectivity.',
165
+ },
166
+ ];
167
+ }
168
+
169
+ function missingMapping(
170
+ peer: TailnetComputer,
171
+ checks: Check[],
172
+ count: number
173
+ ): NodeReport {
174
+ return {
175
+ node: peer.dns,
176
+ checks: [
177
+ ...checks,
178
+ {
179
+ id: 'fleet:registration',
180
+ status: 'fail',
181
+ summary: count
182
+ ? 'Roster mapping is ambiguous between entries or computers; no SSH probe was run.'
183
+ : 'Computer is not registered with a verified SSH alias and checkout path; dependencies and authentication are unverified.',
184
+ remedy: {
185
+ action:
186
+ 'Run fleet-node-add with its existing SSH alias and actual checkout path, then rerun fleet-status --tailnet.',
187
+ },
188
+ },
189
+ ],
190
+ };
191
+ }
192
+
193
+ function combine(
194
+ peer: TailnetComputer,
195
+ checks: Check[],
196
+ report: NodeReport,
197
+ reference: Snapshot
198
+ ): NodeReport {
199
+ return {
200
+ ...report,
201
+ node: peer.dns,
202
+ checks: [
203
+ ...checks,
204
+ ...report.checks,
205
+ ...(report.snapshot
206
+ ? compareFingerprints(
207
+ reference.fingerprint,
208
+ report.snapshot.fingerprint
209
+ )
210
+ : []),
211
+ ],
212
+ };
213
+ }
214
+
215
+ function absentNode(node: FleetNode): NodeReport {
216
+ return {
217
+ node: node.id,
218
+ checks: [
219
+ {
220
+ id: 'tailscale:membership',
221
+ status: 'fail',
222
+ summary:
223
+ 'Registered node is absent from visible Tailscale inventory. It was not silently omitted.',
224
+ remedy: {
225
+ action:
226
+ 'Check the node account, tailnet visibility, and the registered SSH alias.',
227
+ },
228
+ },
229
+ ],
230
+ };
231
+ }
@@ -0,0 +1,37 @@
1
+ import type { FleetNode, Runner } from './types';
2
+ import type { TailnetComputer } from './tailscale';
3
+
4
+ export function matchesComputer(host: string, peer: TailnetComputer): boolean {
5
+ const name = host.toLowerCase().replace(/\.$/, '');
6
+ return [peer.dns, peer.dns.split('.')[0], ...peer.ips].includes(name);
7
+ }
8
+
9
+ export async function matchComputers(
10
+ nodes: FleetNode[],
11
+ peers: TailnetComputer[],
12
+ run: Runner
13
+ ): Promise<Map<string, FleetNode[]>> {
14
+ const matches = new Map(peers.map((peer) => [peer.id, [] as FleetNode[]]));
15
+ for (const node of nodes) {
16
+ const resolved = await run(
17
+ [
18
+ 'ssh',
19
+ '-G',
20
+ '-o',
21
+ 'CanonicalizeHostname=no',
22
+ '-o',
23
+ 'PermitLocalCommand=no',
24
+ node.ssh,
25
+ ],
26
+ { timeoutMs: 5_000 }
27
+ );
28
+ const hostname = resolved.ok
29
+ ? /^hostname ([^\r\n]+)$/im.exec(resolved.stdout)?.[1]?.trim()
30
+ : undefined;
31
+ const targets = peers.filter((peer) =>
32
+ matchesComputer(hostname ?? node.ssh, peer)
33
+ );
34
+ for (const peer of targets) matches.get(peer.id)!.push(node);
35
+ }
36
+ return matches;
37
+ }
@@ -0,0 +1,141 @@
1
+ import { isIP } from 'node:net';
2
+ import { record } from './config';
3
+ import type { Check, Runner } from './types';
4
+
5
+ export interface TailnetComputer {
6
+ id: string;
7
+ name: string;
8
+ dns: string;
9
+ os: string;
10
+ ips: string[];
11
+ online: boolean;
12
+ self: boolean;
13
+ }
14
+ export interface Tailnet {
15
+ computers: TailnetComputer[];
16
+ excludedMobile: number;
17
+ checks: Check[];
18
+ }
19
+
20
+ export async function discoverTailnet(run: Runner): Promise<Tailnet> {
21
+ const result = await run(['tailscale', 'status', '--json'], {
22
+ timeoutMs: 15_000,
23
+ });
24
+ if (!result.ok)
25
+ return unavailable(
26
+ 'Tailscale status is unavailable; check the CLI, daemon and account login.'
27
+ );
28
+ try {
29
+ return parseTailnet(JSON.parse(result.stdout) as unknown);
30
+ } catch {
31
+ return unavailable(
32
+ 'Tailscale returned an unsupported status document. Update the CLI and check its daemon.'
33
+ );
34
+ }
35
+ }
36
+
37
+ export function parseTailnet(data: unknown): Tailnet {
38
+ if (!record(data) || data['BackendState'] !== 'Running')
39
+ return unavailable(
40
+ 'Tailscale is not running and signed in. Review tailscale status locally.'
41
+ );
42
+ validateStatusFields(data);
43
+ const peers = Object.values(
44
+ (data['Peer'] as Record<string, unknown> | null) ?? {}
45
+ );
46
+ if (peers.length > 1024) throw new Error('Too many peers');
47
+ const all = [
48
+ parseComputer(data['Self'], true),
49
+ ...peers.map((peer) => parseComputer(peer, false)),
50
+ ];
51
+ if (new Set(all.map((peer) => peer.id)).size !== all.length)
52
+ throw new Error('Duplicate peer');
53
+ const mobile = (peer: TailnetComputer) =>
54
+ ['ios', 'android', 'tvos'].includes(peer.os.toLowerCase());
55
+ const computers = all
56
+ .filter((peer) => !mobile(peer))
57
+ .sort((a, b) => a.name.localeCompare(b.name, 'en'));
58
+ const healthy = healthyDiscovery(data, computers.length);
59
+ return {
60
+ computers,
61
+ excludedMobile: all.filter(mobile).length,
62
+ checks: [
63
+ {
64
+ id: 'tailscale:discovery',
65
+ status: healthy ? 'pass' : 'fail',
66
+ summary: healthy
67
+ ? 'Signed-in Tailscale daemon returned its visible computer inventory.'
68
+ : 'Tailscale reports a daemon/coordination health problem or no computers; discovery may be incomplete.',
69
+ ...(!healthy
70
+ ? {
71
+ remedy: {
72
+ action:
73
+ 'Review tailscale status and network connectivity. Resolve coordination/daemon health before claiming complete coverage.',
74
+ },
75
+ }
76
+ : {}),
77
+ },
78
+ ],
79
+ };
80
+ }
81
+
82
+ function healthyDiscovery(
83
+ data: Record<string, unknown>,
84
+ count: number
85
+ ): boolean {
86
+ const health = (data['Health'] as unknown[] | null | undefined) ?? [];
87
+ return health.length === 0 && count > 0;
88
+ }
89
+
90
+ function validateStatusFields(data: Record<string, unknown>): void {
91
+ const health = data['Health'] ?? [];
92
+ const peers = data['Peer'] ?? {};
93
+ if (!record(data['Self']) || !record(peers) || !Array.isArray(health))
94
+ throw new Error('Unsupported Tailscale status');
95
+ }
96
+
97
+ function parseComputer(value: unknown, self: boolean): TailnetComputer {
98
+ if (!record(value)) throw new Error('Invalid peer');
99
+ const {
100
+ ID: id,
101
+ HostName: name,
102
+ DNSName: dns,
103
+ OS: os,
104
+ TailscaleIPs: ips,
105
+ Online: online,
106
+ } = value;
107
+ if (
108
+ ![id, name, os].every(
109
+ (field) =>
110
+ typeof field === 'string' &&
111
+ field.length > 0 &&
112
+ field.length <= 256 &&
113
+ !/[\x00-\x1f\x7f]/.test(field)
114
+ ) ||
115
+ typeof dns !== 'string' ||
116
+ !/^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}\.?$/.test(dns) ||
117
+ !Array.isArray(ips) ||
118
+ ips.length < 1 ||
119
+ ips.length > 16 ||
120
+ !ips.every((ip) => typeof ip === 'string' && isIP(ip) !== 0) ||
121
+ typeof online !== 'boolean'
122
+ )
123
+ throw new Error('Invalid peer fields');
124
+ return {
125
+ id: id as string,
126
+ name: name as string,
127
+ dns: dns.replace(/\.$/, '').toLowerCase(),
128
+ os: os as string,
129
+ ips: ips as string[],
130
+ online,
131
+ self,
132
+ };
133
+ }
134
+
135
+ function unavailable(summary: string): Tailnet {
136
+ return {
137
+ computers: [],
138
+ excludedMobile: 0,
139
+ checks: [{ id: 'tailscale:discovery', status: 'fail', summary }],
140
+ };
141
+ }