@crvouga/dev-fleet 0.2.0 → 0.4.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 (129) hide show
  1. package/README.md +50 -16
  2. package/ansible/github/git.yml +66 -0
  3. package/ansible/macos/manager.yml +3 -0
  4. package/ansible/macos/policy.json +11 -1
  5. package/ansible/macos/t3-policy.yml +49 -0
  6. package/ansible/monitoring/compose.yml +10 -0
  7. package/ansible/monitoring/container-watchdog.py +118 -0
  8. package/ansible/monitoring/hub-darwin.yml +146 -0
  9. package/ansible/net/remote-probe.sh +5 -0
  10. package/ansible/ubuntu/baseline.yml +2 -0
  11. package/ansible/ubuntu/disk-gc.py +25 -3
  12. package/ansible/ubuntu/disk-guard.py +24 -5
  13. package/ansible/ubuntu/policy.json +51 -1
  14. package/ansible/ubuntu/t3-desktop.yml +19 -0
  15. package/ansible/ubuntu/t3-health.sh +93 -22
  16. package/ansible/ubuntu/t3-idle.py +42 -0
  17. package/ansible/ubuntu/t3-policy.yml +4 -11
  18. package/ansible/ubuntu/tasks/browser.yml +56 -0
  19. package/ansible/ubuntu/tasks/docker.yml +52 -0
  20. package/ansible/ubuntu/tasks/recovery.yml +60 -1
  21. package/ansible/ubuntu/tasks/resources.yml +2 -2
  22. package/ansible/ubuntu/tasks/services.yml +13 -0
  23. package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
  24. package/ansible/ubuntu/wip-guard.py +121 -0
  25. package/bootstrap/doctor.ts +32 -6
  26. package/bootstrap/fleet-remote-bootstrap.sh +31 -4
  27. package/bootstrap/native-t3.ts +22 -11
  28. package/bootstrap/setup.ts +23 -8
  29. package/bootstrap/worker-tools.ts +118 -0
  30. package/cliproxy/README.md +4 -4
  31. package/cliproxy/compose.yaml +4 -0
  32. package/cliproxy/scripts/accounts.py +216 -0
  33. package/cliproxy/scripts/checkpoint.py +30 -2
  34. package/cliproxy/scripts/cli.py +1 -1
  35. package/cliproxy/scripts/clientkeys.py +99 -0
  36. package/cliproxy/scripts/lease.py +80 -0
  37. package/cliproxy/scripts/ops +137 -25
  38. package/cliproxy/scripts/restore-verify +15 -0
  39. package/docs/accounts.md +154 -0
  40. package/docs/authentication.md +90 -0
  41. package/docs/declared.md +5 -1
  42. package/docs/durability.md +26 -0
  43. package/docs/machine-setup.md +26 -15
  44. package/docs/operations.md +87 -31
  45. package/docs/runbooks.md +380 -0
  46. package/docs/state.md +13 -7
  47. package/docs/topology.md +2 -0
  48. package/docs/ubuntu-sync.md +35 -28
  49. package/examples/fleet.json +19 -1
  50. package/llms.txt +3 -0
  51. package/mise.toml +10 -1
  52. package/package.json +2 -2
  53. package/schema/check-ids.json +522 -0
  54. package/schema/fleet.schema.json +244 -1
  55. package/schema/output/check.schema.json +23 -0
  56. package/schema/output/error.schema.json +14 -0
  57. package/schema/output/fleet-report.schema.json +79 -0
  58. package/schema/output/fleet-sync.schema.json +112 -0
  59. package/schema/output/report.schema.json +38 -0
  60. package/src/accounts/checks.ts +220 -0
  61. package/src/accounts/context.ts +76 -0
  62. package/src/accounts/propagate.ts +62 -0
  63. package/src/accounts/roster.ts +85 -0
  64. package/src/accounts/selection.ts +67 -0
  65. package/src/accounts/sessions.ts +120 -0
  66. package/src/agents/account-relay.ts +176 -26
  67. package/src/agents/launch.ts +84 -5
  68. package/src/agents/native-verify.ts +128 -2
  69. package/src/auth-checks.ts +3 -0
  70. package/src/cli/agent-guide.ts +5 -3
  71. package/src/cli/fleet-accounts.ts +253 -0
  72. package/src/cli/fleet-report.ts +126 -0
  73. package/src/cli/fleet-sync-accounts.ts +58 -0
  74. package/src/cli/fleet-sync-manager.ts +31 -4
  75. package/src/cli/fleet-sync-output.ts +3 -2
  76. package/src/cli/fleet-sync-plan.ts +200 -0
  77. package/src/cli/fleet-sync-remote.ts +23 -14
  78. package/src/cli/fleet-sync-schedule.ts +142 -35
  79. package/src/cli/fleet-sync.ts +126 -8
  80. package/src/cli/fleet-watch.ts +198 -0
  81. package/src/cli/fleet.ts +4 -0
  82. package/src/cli/register-fleet.ts +86 -0
  83. package/src/cli/registry.ts +26 -0
  84. package/src/cli/schedule-logs.ts +36 -0
  85. package/src/clock.ts +63 -0
  86. package/src/definition.ts +226 -7
  87. package/src/git-identity.ts +160 -0
  88. package/src/ide/definition.ts +86 -0
  89. package/src/ide/ide.ts +43 -0
  90. package/src/ide/index.ts +5 -0
  91. package/src/ide/orchestrate.ts +50 -0
  92. package/src/ide/t3.ts +111 -0
  93. package/src/machine-health.ts +51 -4
  94. package/src/manager-health.ts +205 -0
  95. package/src/manager-sync.ts +13 -17
  96. package/src/monitoring/hub-backup.ts +63 -17
  97. package/src/monitoring/manager-hub.ts +44 -2
  98. package/src/node-converge-linux.ts +44 -12
  99. package/src/notify.ts +239 -0
  100. package/src/platform/darwin.ts +0 -13
  101. package/src/platform/fallback.ts +0 -8
  102. package/src/platform/linux.ts +0 -10
  103. package/src/platform/t3-policy.ts +33 -6
  104. package/src/platform/t3-service.ts +104 -0
  105. package/src/platform/types.ts +0 -4
  106. package/src/platform/windows.ts +0 -14
  107. package/src/remote-bootstrap.ts +87 -25
  108. package/src/remote-entry.ts +15 -1
  109. package/src/remote-operation.ts +213 -0
  110. package/src/schedule-check.ts +40 -0
  111. package/src/secrets.ts +26 -8
  112. package/src/service-auth.ts +15 -5
  113. package/src/ssh.ts +10 -2
  114. package/src/state/history.ts +172 -0
  115. package/src/state/store.ts +11 -4
  116. package/src/t3/accounts.ts +7 -1
  117. package/src/t3/models.ts +133 -0
  118. package/src/t3/pairing.ts +31 -0
  119. package/src/t3/profile.ts +203 -0
  120. package/src/t3/version.ts +119 -0
  121. package/src/t3-linked-check.ts +1 -1
  122. package/src/tailscale.ts +51 -0
  123. package/src/toolchain-checks.ts +8 -3
  124. package/src/transport.ts +3 -2
  125. package/src/worker-capability-checks.ts +167 -0
  126. package/src/worker-capacity.ts +19 -7
  127. package/src/worker-host-checks.ts +68 -13
  128. package/src/worker-policy.ts +33 -0
  129. package/src/worker-readiness.ts +10 -1
package/src/definition.ts CHANGED
@@ -1,15 +1,29 @@
1
- import { parseWorkerPolicy, type WorkerPolicy } from './worker-policy';
1
+ import {
2
+ parseWorkerPolicy,
3
+ toolProfiles,
4
+ type WorkerPolicy,
5
+ } from './worker-policy';
6
+ import type { ToolProfile } from '../bootstrap/worker-tools';
2
7
  import { readFileSync } from 'node:fs';
3
8
  import { FleetUsageError } from './errors';
4
9
  import { parseConfig, record } from './config';
5
10
  import type { FleetConfig } from './types';
6
11
  import { parseSecretsConfig, type SecretsConfig } from './secrets';
7
12
  import { parseLocation, type BucketLocation } from './state/bucket';
13
+ import {
14
+ parseIdes,
15
+ parseT3,
16
+ type IdeId,
17
+ type T3Definition,
18
+ } from './ide/definition';
19
+ import { parseNotify, type NotifyConfig } from './notify';
8
20
 
9
21
  export interface MachineDefinition {
10
22
  id: string;
11
23
  ssh: string;
12
24
  os: 'linux' | 'macOS' | 'windows';
25
+ /** Tool profiles this worker adds to the fleet-wide ones. */
26
+ toolProfiles?: ToolProfile[];
13
27
  network?: {
14
28
  tailscaleName: string;
15
29
  user: string;
@@ -40,7 +54,7 @@ export interface FleetDefinition {
40
54
  workers: MachineDefinition[];
41
55
  services: {
42
56
  /** Public HTTPS origin of the CLI proxy every node uses. */
43
- proxy: { endpoint: string };
57
+ proxy: { endpoint: string; accounts?: AccountPolicy };
44
58
  monitoring: {
45
59
  hubPort: 8443;
46
60
  /** Public dashboard origin. */
@@ -49,11 +63,62 @@ export interface FleetDefinition {
49
63
  managerAccount: string;
50
64
  /** Read-only Beszel account visitors are signed in as. */
51
65
  viewerAccount: string;
66
+ /** Scheduled off-machine backups fleet-sync sets on the hub. */
67
+ backups?: HubBackups;
52
68
  };
53
69
  };
54
70
  worker?: WorkerPolicy;
71
+ /** The IDEs every machine runs; `["t3"]` when not named (idesOf). */
72
+ ides?: IdeId[];
73
+ /** What every machine's T3 holds beyond its provider instances. */
74
+ t3?: T3Definition;
75
+ /** The Git identity and GitHub credential helper every node, manager
76
+ * included, commits and pushes with.
77
+ */
78
+ git?: GitIdentity;
79
+ /** Where fleet-watch sends notifications; absent, nobody is notified. */
80
+ notify?: NotifyConfig;
55
81
  ubuntu: { release: '26.04'; architectures: ['amd64', 'arm64'] };
56
82
  }
83
+ /** How provider accounts are watched and chosen (src/accounts/). */
84
+ export interface AccountPolicy {
85
+ /** Fewer usable accounts of a provider than this is a warning; none fails. */
86
+ minimumUsable?: number;
87
+ /** Per provider (`claude`, `codex`, `antigravity`): which account an
88
+ * unpinned request goes to first. Accounts cooling down are skipped.
89
+ */
90
+ selection?: Record<string, AccountSelection>;
91
+ }
92
+ export interface AccountSelection {
93
+ strategy: 'fill-first' | 'round-robin' | 'least-recently-limited';
94
+ /** Account labels (sign-in emails) first, in this order; the others
95
+ * follow in the order of their routing prefix.
96
+ */
97
+ order?: string[];
98
+ }
99
+ export interface HubBackups {
100
+ /** Cron expression of the hub's backup schedule. */
101
+ schedule: string;
102
+ /** How many scheduled backups the hub keeps. */
103
+ keep: number;
104
+ /** S3-compatible storage off the manager; its keys are secrets. */
105
+ storage: {
106
+ bucket: string;
107
+ endpoint: string;
108
+ region: string;
109
+ forcePathStyle?: boolean;
110
+ };
111
+ }
112
+ export interface GitIdentity {
113
+ /** `user.name` of every commit. */
114
+ name: string;
115
+ /** `user.email` of every commit. */
116
+ email: string;
117
+ /** A repository each node must reach through git with its own
118
+ * credentials, proving it can push (`worker:git-push-auth`).
119
+ */
120
+ verifyRepository?: string;
121
+ }
57
122
  const ID = /^[a-z][a-z0-9-]{0,62}$/;
58
123
  const HOST = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$/;
59
124
  function fail(): never {
@@ -90,7 +155,10 @@ function validPort(value: unknown): boolean {
90
155
  );
91
156
  }
92
157
  function worker(value: unknown): MachineDefinition {
93
- if (!record(value) || !keys(value, ['id', 'ssh', 'os', 'network']))
158
+ if (
159
+ !record(value) ||
160
+ !keys(value, ['id', 'ssh', 'os', 'network', 'toolProfiles'])
161
+ )
94
162
  return fail();
95
163
  if (typeof value['id'] !== 'string' || !ID.test(value['id'])) return fail();
96
164
  if (typeof value['ssh'] !== 'string' || !HOST.test(value['ssh']))
@@ -102,6 +170,9 @@ function worker(value: unknown): MachineDefinition {
102
170
  ssh: value['ssh'],
103
171
  os: 'linux',
104
172
  ...(value['network'] ? { network: network(value['network']) } : {}),
173
+ ...(value['toolProfiles'] === undefined
174
+ ? {}
175
+ : { toolProfiles: toolProfiles(value['toolProfiles']) ?? fail() }),
105
176
  };
106
177
  }
107
178
  function manager(value: unknown): ManagerDefinition {
@@ -148,6 +219,10 @@ export function parseDefinition(value: unknown): FleetDefinition {
148
219
  'services',
149
220
  'ubuntu',
150
221
  'worker',
222
+ 'ides',
223
+ 't3',
224
+ 'git',
225
+ 'notify',
151
226
  ])
152
227
  )
153
228
  return fail();
@@ -166,9 +241,25 @@ export function parseDefinition(value: unknown): FleetDefinition {
166
241
  workers,
167
242
  services: services(value['services']),
168
243
  ubuntu: baseline(value['ubuntu']),
244
+ ...optionalSections(value),
245
+ };
246
+ }
247
+ /** The sections a fleet may leave out, kept only when named: an earlier
248
+ * revision must still read the copy.
249
+ */
250
+ function optionalSections(
251
+ value: Record<string, unknown>
252
+ ): Pick<FleetDefinition, 'worker' | 'ides' | 't3' | 'git' | 'notify'> {
253
+ return {
169
254
  ...(value['worker'] === undefined
170
255
  ? {}
171
256
  : { worker: parseWorkerPolicy(value['worker']) }),
257
+ ...(value['ides'] === undefined ? {} : { ides: parseIdes(value['ides']) }),
258
+ ...(value['t3'] === undefined ? {} : { t3: parseT3(value['t3']) }),
259
+ ...(value['git'] === undefined ? {} : { git: gitIdentity(value['git']) }),
260
+ ...(value['notify'] === undefined
261
+ ? {}
262
+ : { notify: parseNotify(value['notify']) ?? fail() }),
172
263
  };
173
264
  }
174
265
  /** Singleton services name no host: they always run on the manager. */
@@ -177,31 +268,159 @@ function services(value: unknown): FleetDefinition['services'] {
177
268
  const { proxy, monitoring } = value;
178
269
  if (
179
270
  !record(proxy) ||
180
- !keys(proxy, ['endpoint']) ||
271
+ !keys(proxy, ['endpoint', 'accounts']) ||
181
272
  !origin(proxy['endpoint'])
182
273
  )
183
274
  return fail();
184
275
  return {
185
- proxy: { endpoint: proxy['endpoint'] },
276
+ proxy: {
277
+ endpoint: proxy['endpoint'],
278
+ ...(proxy['accounts'] === undefined
279
+ ? {}
280
+ : { accounts: accountPolicy(proxy['accounts']) }),
281
+ },
186
282
  monitoring: monitoringService(monitoring),
187
283
  };
188
284
  }
285
+ const STRATEGIES = ['fill-first', 'round-robin', 'least-recently-limited'];
286
+ function accountPolicy(value: unknown): AccountPolicy {
287
+ if (!record(value) || !keys(value, ['minimumUsable', 'selection']))
288
+ return fail();
289
+ const { minimumUsable, selection } = value;
290
+ if (
291
+ minimumUsable !== undefined &&
292
+ !(Number.isInteger(minimumUsable) && Number(minimumUsable) >= 1)
293
+ )
294
+ return fail();
295
+ return {
296
+ ...(minimumUsable === undefined
297
+ ? {}
298
+ : { minimumUsable: minimumUsable as number }),
299
+ ...(selection === undefined
300
+ ? {}
301
+ : { selection: selectionPolicies(selection) }),
302
+ };
303
+ }
304
+ function selectionPolicies(value: unknown): Record<string, AccountSelection> {
305
+ if (!record(value)) return fail();
306
+ const providers: Record<string, AccountSelection> = {};
307
+ for (const [provider, entry] of Object.entries(value)) {
308
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(provider)) return fail();
309
+ providers[provider] = accountSelection(entry);
310
+ }
311
+ return providers;
312
+ }
313
+ function accountSelection(entry: unknown): AccountSelection {
314
+ if (
315
+ !record(entry) ||
316
+ !keys(entry, ['strategy', 'order']) ||
317
+ !STRATEGIES.includes(String(entry['strategy']))
318
+ )
319
+ return fail();
320
+ const { order } = entry;
321
+ if (
322
+ order !== undefined &&
323
+ !(Array.isArray(order) && order.every((label) => account(label)))
324
+ )
325
+ return fail();
326
+ return {
327
+ strategy: entry['strategy'] as AccountSelection['strategy'],
328
+ ...(order ? { order: order as string[] } : {}),
329
+ };
330
+ }
189
331
  function monitoringService(
190
332
  value: unknown
191
333
  ): FleetDefinition['services']['monitoring'] {
192
- const fields = ['hubPort', 'publicUrl', 'managerAccount', 'viewerAccount'];
334
+ const fields = [
335
+ 'hubPort',
336
+ 'publicUrl',
337
+ 'managerAccount',
338
+ 'viewerAccount',
339
+ 'backups',
340
+ ];
193
341
  if (!record(value) || !keys(value, fields) || value['hubPort'] !== 8443)
194
342
  return fail();
195
343
  const { publicUrl, managerAccount, viewerAccount } = value;
196
344
  if (!origin(publicUrl) || !account(managerAccount) || !account(viewerAccount))
197
345
  return fail();
198
- return { hubPort: 8443, publicUrl, managerAccount, viewerAccount };
346
+ return {
347
+ hubPort: 8443,
348
+ publicUrl,
349
+ managerAccount,
350
+ viewerAccount,
351
+ ...(value['backups'] === undefined
352
+ ? {}
353
+ : { backups: hubBackups(value['backups']) }),
354
+ };
355
+ }
356
+ const text = (value: unknown, pattern: RegExp): value is string =>
357
+ typeof value === 'string' && pattern.test(value);
358
+ function hubBackups(value: unknown): HubBackups {
359
+ if (!record(value) || !keys(value, ['schedule', 'keep', 'storage']))
360
+ return fail();
361
+ const { schedule, keep } = value;
362
+ if (
363
+ !text(schedule, /^[0-9*/,-]+( [0-9*/,-]+){4}$/) ||
364
+ !Number.isInteger(keep) ||
365
+ Number(keep) < 1 ||
366
+ Number(keep) > 365
367
+ )
368
+ return fail();
369
+ return {
370
+ schedule,
371
+ keep: Number(keep),
372
+ storage: backupStorage(value['storage']),
373
+ };
374
+ }
375
+ function backupStorage(value: unknown): HubBackups['storage'] {
376
+ if (
377
+ !record(value) ||
378
+ !keys(value, ['bucket', 'endpoint', 'region', 'forcePathStyle'])
379
+ )
380
+ return fail();
381
+ const { bucket, endpoint, region, forcePathStyle } = value;
382
+ if (
383
+ !text(bucket, /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/) ||
384
+ !text(endpoint, /^https:\/\/[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$/) ||
385
+ !text(region, /^[a-z0-9-]{1,32}$/) ||
386
+ (forcePathStyle !== undefined && typeof forcePathStyle !== 'boolean')
387
+ )
388
+ return fail();
389
+ return {
390
+ bucket,
391
+ endpoint,
392
+ region,
393
+ ...(forcePathStyle === undefined ? {} : { forcePathStyle }),
394
+ };
199
395
  }
200
396
  const origin = (value: unknown): value is string =>
201
397
  typeof value === 'string' && /^https:\/\/[A-Za-z0-9.-]{1,253}$/.test(value);
202
398
  const account = (value: unknown): value is string =>
203
399
  typeof value === 'string' &&
204
400
  /^[^\s@]{1,64}@[A-Za-z0-9.-]{1,253}$/.test(value);
401
+ function gitIdentity(value: unknown): GitIdentity {
402
+ if (!record(value) || !keys(value, ['name', 'email', 'verifyRepository']))
403
+ return fail();
404
+ const { name, email, verifyRepository } = value;
405
+ // Written into a quoted Git configuration value: no quote, backslash or
406
+ // line break.
407
+ if (typeof name !== 'string' || !/^[^"\\\n\r]{1,100}$/.test(name))
408
+ return fail();
409
+ if (!account(email)) return fail();
410
+ if (
411
+ verifyRepository !== undefined &&
412
+ (typeof verifyRepository !== 'string' ||
413
+ !/^https:\/\/github\.com\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/.test(
414
+ verifyRepository
415
+ ))
416
+ )
417
+ return fail();
418
+ return {
419
+ name,
420
+ email,
421
+ ...(verifyRepository === undefined ? {} : { verifyRepository }),
422
+ };
423
+ }
205
424
  function secrets(value: unknown): SecretsConfig {
206
425
  return parseSecretsConfig(value) ?? fail();
207
426
  }
@@ -0,0 +1,160 @@
1
+ import { homedir } from 'node:os';
2
+ import { resolve } from 'node:path';
3
+ import { runAnsible } from './platform/ansible';
4
+ import type { GitIdentity } from './definition';
5
+ import type { Check, Runner } from './types';
6
+
7
+ /** `ansible/github/git.yml` writes the declared identity and the GitHub
8
+ * credential helper in the node's own Git configuration; the two checks
9
+ * below measure them afterwards. The same on the manager and every worker.
10
+ */
11
+ export async function convergeGitIdentity(
12
+ root: string,
13
+ git: GitIdentity | undefined,
14
+ run: Runner,
15
+ home: string = homedir()
16
+ ): Promise<Check[]> {
17
+ if (!git) return [];
18
+ const result = await runAnsible(
19
+ run,
20
+ resolve(root, 'ansible/github/git.yml'),
21
+ {
22
+ cwd: resolve(root, 'ansible/github'),
23
+ timeoutMs: 120_000,
24
+ input: JSON.stringify({
25
+ git_name: git.name,
26
+ git_email: git.email,
27
+ git_home: home,
28
+ }),
29
+ }
30
+ );
31
+ return [
32
+ {
33
+ id: 'git:configuration',
34
+ status: result.ok ? 'pass' : 'fail',
35
+ summary: result.ok
36
+ ? 'The declared Git identity and the GitHub credential helper are written in ~/.config/git/config.'
37
+ : 'GIT_CONFIGURATION_INCOMPLETE',
38
+ ...(result.ok
39
+ ? {}
40
+ : {
41
+ remedy: {
42
+ action:
43
+ 'Inspect ~/.config/git/config and ~/.gitconfig on this node: a ~/.gitconfig that sets user.name, user.email or a GitHub credential helper wins over the fleet. Remove those lines and rerun fleet-sync.',
44
+ },
45
+ }),
46
+ },
47
+ ];
48
+ }
49
+
50
+ /** Git with no prompt: a missing credential fails instead of waiting. */
51
+ const QUIET = {
52
+ GIT_TERMINAL_PROMPT: '0',
53
+ GCM_INTERACTIVE: 'never',
54
+ GIT_ASKPASS: '',
55
+ SSH_ASKPASS: '',
56
+ };
57
+
58
+ async function effective(run: Runner, key: string): Promise<string> {
59
+ const result = await run(['git', 'config', '--global', '--get-all', key], {
60
+ timeoutMs: 10_000,
61
+ });
62
+ return result.ok ? (result.stdout.trim().split('\n').pop() ?? '') : '';
63
+ }
64
+
65
+ /** Commits carry the declared author: the effective `user.name` and
66
+ * `user.email` outside any repository are the fleet's.
67
+ */
68
+ export async function gitIdentityCheck(
69
+ git: GitIdentity | undefined,
70
+ run: Runner
71
+ ): Promise<Check> {
72
+ const id = 'worker:git-identity';
73
+ if (!git)
74
+ return {
75
+ id,
76
+ status: 'warn',
77
+ summary: 'GIT_IDENTITY_UNDECLARED',
78
+ remedy: {
79
+ action:
80
+ 'Declare git.name and git.email in fleet.json and rerun fleet-sync; until then commits on this node carry whatever author it happens to have.',
81
+ },
82
+ };
83
+ const name = await effective(run, 'user.name');
84
+ const email = await effective(run, 'user.email');
85
+ if (name === git.name && email === git.email)
86
+ return {
87
+ id,
88
+ status: 'pass',
89
+ summary: `Commits on this node are authored as ${git.name} <${git.email}>.`,
90
+ };
91
+ return {
92
+ id,
93
+ status: 'fail',
94
+ summary: !name || !email ? 'GIT_IDENTITY_MISSING' : 'GIT_IDENTITY_MISMATCH',
95
+ remedy: {
96
+ action:
97
+ 'Rerun fleet-sync: it writes the identity in ~/.config/git/config. A ~/.gitconfig that sets another user.name or user.email wins over it; remove those lines.',
98
+ },
99
+ };
100
+ }
101
+
102
+ /** A push is authorized through git itself, never through gh: Git's
103
+ * credential helper must answer for github.com, and the declared repository
104
+ * must answer an authenticated listing. The credential is read and dropped;
105
+ * it is never kept, printed or reported.
106
+ */
107
+ export async function gitPushAuthCheck(
108
+ git: GitIdentity | undefined,
109
+ run: Runner
110
+ ): Promise<Check> {
111
+ const id = 'worker:git-push-auth';
112
+ const fill = await run(['git', 'credential', 'fill'], {
113
+ input: 'protocol=https\nhost=github.com\n\n',
114
+ env: QUIET,
115
+ timeoutMs: 20_000,
116
+ });
117
+ if (!fill.ok || !/^password=./m.test(fill.stdout))
118
+ return {
119
+ id,
120
+ status: 'fail',
121
+ summary: 'GIT_CREDENTIAL_HELPER_MISSING',
122
+ remedy: {
123
+ action:
124
+ 'Git has no credential for github.com, so an agent cannot push over HTTPS. Rerun fleet-sync, which declares gh as the credential helper; if gh is signed out, the worker signs in again from its own Vault session (auth:github).',
125
+ },
126
+ };
127
+ if (git?.verifyRepository) {
128
+ const listed = await run(
129
+ ['git', 'ls-remote', '--heads', git.verifyRepository],
130
+ { env: QUIET, timeoutMs: 30_000 }
131
+ );
132
+ if (!listed.ok)
133
+ return {
134
+ id,
135
+ status: 'fail',
136
+ summary:
137
+ listed.reason === 'timeout'
138
+ ? 'GIT_REMOTE_UNREACHABLE'
139
+ : 'GIT_REMOTE_REFUSED',
140
+ remedy: {
141
+ action:
142
+ "The declared repository (git.verifyRepository) did not answer git with this node's credentials. Check that the fleet's GitHub account can push to it and that github.com is reachable.",
143
+ },
144
+ };
145
+ }
146
+ return {
147
+ id,
148
+ status: 'pass',
149
+ summary: git?.verifyRepository
150
+ ? "Git's credential helper answers for github.com and the declared repository answers through git."
151
+ : "Git's credential helper answers for github.com; declare git.verifyRepository to prove a repository too.",
152
+ };
153
+ }
154
+
155
+ export async function gitChecks(
156
+ git: GitIdentity | undefined,
157
+ run: Runner
158
+ ): Promise<Check[]> {
159
+ return [await gitIdentityCheck(git, run), await gitPushAuthCheck(git, run)];
160
+ }
@@ -0,0 +1,86 @@
1
+ import { FleetUsageError } from '../errors';
2
+ import { record } from '../config';
3
+
4
+ /** The IDEs dev-fleet supports. The support matrix in the README lists the
5
+ * same; enabling one on the fleet is `ides` in fleet.json, not new code.
6
+ */
7
+ export const SUPPORTED_IDES = ['t3'] as const;
8
+ export type IdeId = (typeof SUPPORTED_IDES)[number];
9
+
10
+ /** What `t3` in fleet.json declares for every machine's T3. */
11
+ export interface T3Definition {
12
+ /** Top-level T3 settings every machine holds the same: tool servers,
13
+ * projects, selected preferences. A key not named here stays local.
14
+ */
15
+ profile?: Record<string, unknown>;
16
+ /** Models T3 must offer for every account of a tool, by tool. */
17
+ requiredModels?: Record<string, string[]>;
18
+ }
19
+
20
+ const KEY = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
21
+ /** Settings another part of dev-fleet owns. */
22
+ const RESERVED = ['providerInstances'];
23
+
24
+ function invalid(detail: string): never {
25
+ throw new FleetUsageError(`Invalid fleet.json: ${detail}`);
26
+ }
27
+
28
+ /** The IDEs a fleet runs: those it names, or T3 alone. */
29
+ export const idesOf = (definition: { ides?: readonly IdeId[] }): IdeId[] => [
30
+ ...(definition.ides ?? ['t3']),
31
+ ];
32
+
33
+ export function parseIdes(value: unknown): IdeId[] {
34
+ if (
35
+ !Array.isArray(value) ||
36
+ value.length === 0 ||
37
+ new Set(value).size !== value.length ||
38
+ value.some((id) => !SUPPORTED_IDES.includes(id as IdeId))
39
+ )
40
+ invalid(`ides lists each of ${SUPPORTED_IDES.join(', ')} at most once.`);
41
+ return value as IdeId[];
42
+ }
43
+
44
+ export function parseT3(value: unknown): T3Definition {
45
+ if (
46
+ !record(value) ||
47
+ Object.keys(value).some(
48
+ (key) => !['profile', 'requiredModels'].includes(key)
49
+ )
50
+ )
51
+ invalid('t3 holds only profile and requiredModels.');
52
+ const result: T3Definition = {};
53
+ if (value['profile'] !== undefined) {
54
+ const profile = value['profile'];
55
+ if (
56
+ !record(profile) ||
57
+ Object.keys(profile).some(
58
+ (key) => !KEY.test(key) || RESERVED.includes(key)
59
+ ) ||
60
+ JSON.stringify(profile).length > 65_536
61
+ )
62
+ invalid(
63
+ 't3.profile names top-level T3 settings (not providerInstances) and stays under 64 KiB.'
64
+ );
65
+ result.profile = profile;
66
+ }
67
+ if (value['requiredModels'] !== undefined) {
68
+ const required = value['requiredModels'];
69
+ if (
70
+ !record(required) ||
71
+ Object.entries(required).some(
72
+ ([tool, models]) =>
73
+ !KEY.test(tool) ||
74
+ !Array.isArray(models) ||
75
+ models.some(
76
+ (model) =>
77
+ typeof model !== 'string' ||
78
+ !/^[A-Za-z0-9._:-]{1,128}$/.test(model)
79
+ )
80
+ )
81
+ )
82
+ invalid('t3.requiredModels lists model names by tool.');
83
+ result.requiredModels = required as Record<string, string[]>;
84
+ }
85
+ return result;
86
+ }
package/src/ide/ide.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { Check, FleetNode, Runner } from '../types';
2
+
3
+ /** What every supported IDE is asked for. An implementation owns its own
4
+ * install, service and policy (an Ansible role per IDE, with the common
5
+ * variable contract: version, checksum, policy, instances, profile) and is
6
+ * reached only through these calls, so the orchestration never names one.
7
+ */
8
+ export interface Ide {
9
+ /** The name the fleet configuration enables it by, under `ides`. */
10
+ readonly id: string;
11
+ /** The IDE's settings on this machine: provider instances from the account
12
+ * roster, the declared profile and the models it offers. Run on the manager
13
+ * and on every worker by the machine itself.
14
+ */
15
+ settings(context: MachineContext): Promise<Check[]>;
16
+ /** The manager's own IDE: its settings, its pinned version and its policy. */
17
+ manager(context: MachineContext): Promise<Check[]>;
18
+ /** What the manager measures of a worker's IDE: remote access, pairing and
19
+ * the version the worker runs.
20
+ */
21
+ worker(node: FleetNode, context: WorkerContext): Promise<Check[]>;
22
+ }
23
+
24
+ export interface MachineContext {
25
+ /** The node ID this machine is in the fleet. */
26
+ id: string;
27
+ /** The checkout this run applies. */
28
+ workspace: string;
29
+ apply: boolean;
30
+ run: Runner;
31
+ }
32
+
33
+ export interface WorkerContext {
34
+ run: Runner;
35
+ /** The manager's checkout of the revision this sync applies. */
36
+ workspace: string;
37
+ /** The checks this sync already reported for the worker. */
38
+ earlier: Check[];
39
+ /** Records what identifies the worker's IDE in the fleet state, the first
40
+ * time it is seen through a verified link.
41
+ */
42
+ remember?: (node: string, identity: string) => Promise<void>;
43
+ }
@@ -0,0 +1,5 @@
1
+ import type { Ide } from './ide';
2
+ import { t3Ide } from './t3';
3
+
4
+ /** Every IDE dev-fleet implements, by the ID fleet.json enables it with. */
5
+ export const IDES: Readonly<Record<string, Ide>> = { t3: t3Ide() };
@@ -0,0 +1,50 @@
1
+ import type { Check, FleetNode } from '../types';
2
+ import type { Ide, MachineContext, WorkerContext } from './ide';
3
+
4
+ /** The implementations the fleet enables, in the order it names them. An ID
5
+ * nobody implements is refused before anything runs.
6
+ */
7
+ export function enabled(
8
+ ids: readonly string[],
9
+ registry: Readonly<Record<string, Ide>>
10
+ ): Ide[] {
11
+ return ids.map((id) => {
12
+ const ide = registry[id];
13
+ if (!ide) throw new Error(`Unsupported IDE: ${id}`);
14
+ return ide;
15
+ });
16
+ }
17
+
18
+ export async function settingsChecks(
19
+ ides: Ide[],
20
+ context: MachineContext
21
+ ): Promise<Check[]> {
22
+ const checks: Check[] = [];
23
+ for (const ide of ides) checks.push(...(await ide.settings(context)));
24
+ return checks;
25
+ }
26
+
27
+ export async function managerChecks(
28
+ ides: Ide[],
29
+ context: MachineContext
30
+ ): Promise<Check[]> {
31
+ const checks: Check[] = [];
32
+ for (const ide of ides) checks.push(...(await ide.manager(context)));
33
+ return checks;
34
+ }
35
+
36
+ export async function workerChecks(
37
+ ides: Ide[],
38
+ node: FleetNode,
39
+ context: WorkerContext
40
+ ): Promise<Check[]> {
41
+ const checks: Check[] = [];
42
+ for (const ide of ides)
43
+ checks.push(
44
+ ...(await ide.worker(node, {
45
+ ...context,
46
+ earlier: [...context.earlier, ...checks],
47
+ }))
48
+ );
49
+ return checks;
50
+ }