@crvouga/dev-fleet 0.5.0 → 0.7.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 (56) hide show
  1. package/README.md +4 -10
  2. package/ansible/macos/manager.yml +39 -2
  3. package/ansible/macos/policy.json +6 -1
  4. package/ansible/monitoring/agent.yml +2 -0
  5. package/ansible/ubuntu/baseline.yml +8 -0
  6. package/ansible/ubuntu/recovery.yml +2 -0
  7. package/ansible/ubuntu/tailscale.yml +2 -0
  8. package/ansible/ubuntu/tasks/github.yml +50 -0
  9. package/bootstrap/worker-auth.ts +6 -7
  10. package/bootstrap/worker-github.ts +55 -44
  11. package/docs/authentication.md +4 -1
  12. package/docs/consumer-setup.md +7 -4
  13. package/docs/github.md +107 -0
  14. package/docs/manager-recovery.md +78 -0
  15. package/docs/operations.md +9 -1
  16. package/docs/runbooks.md +15 -1
  17. package/docs/state.md +38 -6
  18. package/docs/topology.md +4 -5
  19. package/docs/ubuntu-sync.md +5 -5
  20. package/package.json +2 -2
  21. package/schema/check-ids.json +51 -3
  22. package/schema/fleet.schema.json +79 -1
  23. package/src/auth-checks.ts +14 -6
  24. package/src/check-owners.ts +163 -0
  25. package/src/cli/fleet-restore.ts +205 -0
  26. package/src/cli/fleet-state.ts +57 -5
  27. package/src/cli/fleet-sync-manager.ts +62 -15
  28. package/src/cli/fleet-sync-state.ts +18 -8
  29. package/src/cli/fleet-sync.ts +32 -3
  30. package/src/cli/github-verify.ts +57 -0
  31. package/src/cli/register-fleet.ts +33 -0
  32. package/src/cli/registry.ts +21 -1
  33. package/src/definition.ts +11 -1
  34. package/src/github/checks.ts +412 -0
  35. package/src/github/converge.ts +62 -0
  36. package/src/github/copilot.ts +54 -0
  37. package/src/github/credential.ts +21 -0
  38. package/src/github/policy.ts +141 -0
  39. package/src/github/sync.ts +123 -0
  40. package/src/github/workflow.ts +223 -0
  41. package/src/node-converge-linux.ts +3 -2
  42. package/src/node-converge-shared.ts +6 -2
  43. package/src/platform/manager-host.ts +184 -0
  44. package/src/repair-catalog.ts +66 -2
  45. package/src/repair-plan.ts +25 -13
  46. package/src/secrets.ts +58 -13
  47. package/src/service-auth.ts +2 -2
  48. package/src/state/check.ts +7 -0
  49. package/src/state/holder.ts +50 -0
  50. package/src/state/litestream.ts +6 -3
  51. package/src/state/migrations/0001-documents.sql +13 -0
  52. package/src/state/migrations/0002-lease.sql +7 -0
  53. package/src/state/store.ts +125 -26
  54. package/src/state/sync.ts +131 -10
  55. package/src/worker-access-checks.ts +66 -16
  56. package/src/worker-readiness.ts +1 -1
@@ -11,6 +11,8 @@ import { fleetAccounts, type FleetAccountsOptions } from './fleet-accounts';
11
11
  import { fleetReport, type FleetReportOptions } from './fleet-report';
12
12
  import { fleetWatch, watchServices } from './fleet-watch';
13
13
  import type { NodeReport } from '../types';
14
+ import { registerGithubVerify } from './github-verify';
15
+ import { fleetRestore, type FleetRestoreOptions } from './fleet-restore';
14
16
 
15
17
  type Complete = (outcome: CommandOutcome) => void;
16
18
 
@@ -198,6 +200,10 @@ function registerSync({ program, defaults, complete }: Registration) {
198
200
  'Accepted for compatibility; fleet-sync applies by default (schedule writes still need --apply --yes)'
199
201
  )
200
202
  .option('--yes', 'Accepted for compatibility with --apply')
203
+ .option(
204
+ '--take-over',
205
+ 'Make this machine the manager while another one still holds the manager lease'
206
+ )
201
207
  .option(
202
208
  '--schedule <action>',
203
209
  'Manager-only schedule: enable, pause, resume, disable, status'
@@ -276,6 +282,14 @@ function registerState({ program, complete }: Registration) {
276
282
  'Replace this machine’s copy with the bucket’s version'
277
283
  )
278
284
  .option('--yes', 'With --restore: discard changes the bucket does not hold')
285
+ .option(
286
+ '--timestamp <time>',
287
+ 'With --restore: the version the bucket held at this time, uploaded again as the current one (a damaged replica)'
288
+ )
289
+ .option(
290
+ '--take-over',
291
+ 'With --init or --import: change the fleet state while another manager still holds the manager lease'
292
+ )
279
293
  .addHelpText(
280
294
  'after',
281
295
  '\nRead-only without a flag. The bucket is the one named under "state" in fleet.json. Every command reads its credentials from DEVFLEET_BUCKET_ACCESS_KEY_ID and DEVFLEET_BUCKET_SECRET_ACCESS_KEY; --init also asks on the terminal. They are never taken from arguments.'
@@ -331,6 +345,23 @@ function registerWatch({ program, defaults, complete }: Registration) {
331
345
  });
332
346
  }
333
347
 
348
+ function registerRestore({ program, complete }: Registration) {
349
+ program
350
+ .command('fleet-restore')
351
+ .description(
352
+ 'Bring a replacement manager back from the bucket (docs/manager-recovery.md)'
353
+ )
354
+ .option(
355
+ '--dry-run',
356
+ 'Verify every prerequisite and list what would be restored; change nothing'
357
+ )
358
+ .action(async (_options, command: Command) => {
359
+ complete(
360
+ await fleetRestore(command.optsWithGlobals<FleetRestoreOptions>())
361
+ );
362
+ });
363
+ }
364
+
334
365
  export function registerFleet(
335
366
  program: Command,
336
367
  defaults: Settings,
@@ -347,9 +378,11 @@ export function registerFleet(
347
378
  registerState(registration);
348
379
  registerReport(registration);
349
380
  registerWatch(registration);
381
+ registerRestore(registration);
350
382
  registerRepair(registration);
351
383
  registerNodes(registration);
352
384
  registerAccounts(registration);
385
+ registerGithubVerify(program, complete);
353
386
  }
354
387
 
355
388
  function registerAccounts({ program, defaults, complete }: Registration) {
@@ -93,7 +93,7 @@ export const COMMANDS: CommandSpec[] = [
93
93
  [
94
94
  'fleet-state --restore',
95
95
  'Replace the local fleet state with the bucket’s',
96
- 'Discard this manager’s copy of the fleet state and download the bucket’s version. Needs --yes when this machine has changes the bucket does not hold.',
96
+ 'Discard this manager’s copy of the fleet state and download the bucket’s version. Needs --yes when this machine has changes the bucket does not hold. --timestamp TIME --yes restores the version the bucket held then and uploads it again: the remedy for a corrupt replica.',
97
97
  ['state', 'bucket', 'restore', 'recover', 'disposable'],
98
98
  true,
99
99
  ],
@@ -103,6 +103,19 @@ export const COMMANDS: CommandSpec[] = [
103
103
  'Read-only: success rate, mean duration, the most frequent failing check IDs and how long each machine was not ready, from the history of fleet-sync runs kept in the fleet state. --since 30d (default), 12h or 90m.',
104
104
  ['history', 'reliability', 'report', 'failures', 'sync'],
105
105
  ],
106
+ [
107
+ 'fleet-restore --dry-run',
108
+ 'Check that a replacement manager can be restored',
109
+ 'Read-only: verifies every prerequisite of a manager restore (fleet.json, the bucket and the state in it, the local host name, Git, Tailscale, the container engine) and lists what would be restored, in order. docs/manager-recovery.md is the runbook.',
110
+ ['manager', 'restore', 'replace', 'lost', 'recovery', 'runbook'],
111
+ ],
112
+ [
113
+ 'fleet-restore',
114
+ 'Restore a replacement manager from the bucket',
115
+ 'Runs the same checks, then takes the fleet state from the bucket. fleet-sync rebuilds the rest.',
116
+ ['manager', 'restore', 'replace', 'lost', 'recovery'],
117
+ true,
118
+ ],
106
119
  [
107
120
  'fleet-node-sync',
108
121
  'Sync this node',
@@ -161,6 +174,13 @@ export const COMMANDS: CommandSpec[] = [
161
174
  ['accounts', 'login', 'relogin', 'remove', 'add', 'sign in'],
162
175
  true,
163
176
  ],
177
+ [
178
+ 'fleet-github-verify',
179
+ 'Verify the GitHub workflow from this machine',
180
+ 'Against the sandbox repository fleet.json declares (github.sandbox): clone, push a uniquely named branch, open a draft pull request, read its checks and comment, then close it and delete the branch whatever failed. Fails at the named step.',
181
+ ['github', 'verify', 'pull request', 'push', 'permissions', 'workflow'],
182
+ true,
183
+ ],
164
184
  ]),
165
185
  ...group('T3', [
166
186
  [
package/src/definition.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  } from './ide/definition';
19
19
  import { parseNotify, type NotifyConfig } from './notify';
20
20
  import { ubuntuRelease } from './platform/ubuntu-release';
21
+ import { parseGithubPolicy, type GithubPolicy } from './github/policy';
21
22
 
22
23
  export interface MachineDefinition {
23
24
  id: string;
@@ -82,6 +83,8 @@ export interface FleetDefinition {
82
83
  /** An Ubuntu release ansible/ubuntu/policy.json accepts, with exactly the
83
84
  * architectures the policy names for it. */
84
85
  ubuntu: { release: string; architectures: string[] };
86
+ /** How every node signs in to GitHub; defaults when absent. */
87
+ github?: GithubPolicy;
85
88
  }
86
89
  /** How provider accounts are watched and chosen (src/accounts/). */
87
90
  export interface AccountPolicy {
@@ -226,6 +229,7 @@ export function parseDefinition(value: unknown): FleetDefinition {
226
229
  't3',
227
230
  'git',
228
231
  'notify',
232
+ 'github',
229
233
  ])
230
234
  )
231
235
  return fail();
@@ -252,7 +256,10 @@ export function parseDefinition(value: unknown): FleetDefinition {
252
256
  */
253
257
  function optionalSections(
254
258
  value: Record<string, unknown>
255
- ): Pick<FleetDefinition, 'worker' | 'ides' | 't3' | 'git' | 'notify'> {
259
+ ): Pick<
260
+ FleetDefinition,
261
+ 'worker' | 'ides' | 't3' | 'git' | 'notify' | 'github'
262
+ > {
256
263
  return {
257
264
  ...(value['worker'] === undefined
258
265
  ? {}
@@ -263,6 +270,9 @@ function optionalSections(
263
270
  ...(value['notify'] === undefined
264
271
  ? {}
265
272
  : { notify: parseNotify(value['notify']) ?? fail() }),
273
+ ...(value['github'] === undefined
274
+ ? {}
275
+ : { github: parseGithubPolicy(value['github']) }),
266
276
  };
267
277
  }
268
278
  /** Singleton services name no host: they always run on the manager. */
@@ -0,0 +1,412 @@
1
+ import type { FleetDefinition } from '../definition';
2
+ import type { SecretsProvider } from '../secrets';
3
+ import type { Check, Runner } from '../types';
4
+ import { githubCredential } from './credential';
5
+ import { githubNode, githubPolicy, type GithubNode } from './policy';
6
+ import { activeToken, GITHUB_STEP_TIMEOUT_MS } from './sync';
7
+
8
+ export interface GithubCheckInput {
9
+ definition: FleetDefinition;
10
+ /** The node measured; the manager when it is not a declared worker. */
11
+ node?: string;
12
+ secret: SecretsProvider;
13
+ now?: () => Date;
14
+ }
15
+
16
+ const DAY = 86_400_000;
17
+ const EXPIRY_WARN_DAYS = 14;
18
+ const EXPIRY_FAIL_DAYS = 3;
19
+ const RATE_WARN_FRACTION = 0.2;
20
+
21
+ /** GitHub sign-in on any node, manager or worker: what the token is, what it
22
+ * can do and for how long. One request to GitHub answers the scopes, the
23
+ * expiry and the rate limit together; nothing it prints is kept.
24
+ */
25
+ export async function githubChecks(
26
+ run: Runner,
27
+ input: GithubCheckInput
28
+ ): Promise<Check[]> {
29
+ const target = githubNode(input.definition, input.node);
30
+ const policy = githubPolicy(input.definition);
31
+ const status = await run(['gh', 'auth', 'status', '--active'], {
32
+ timeoutMs: 15_000,
33
+ });
34
+ const checks: Check[] = [
35
+ {
36
+ id: 'auth:github',
37
+ status: status.ok ? 'pass' : 'fail',
38
+ summary: status.ok
39
+ ? 'github check succeeded in this process context.'
40
+ : `github check failed (${status.reason}); no credential output retained.`,
41
+ ...(status.ok
42
+ ? {}
43
+ : {
44
+ remedy: {
45
+ action: target.managed
46
+ ? 'Put the fleet GitHub token in the secrets provider under github-token, then run fleet-sync: it signs this node in. No interactive login is needed.'
47
+ : 'Authenticate on this node with gh auth login, then gh auth setup-git.',
48
+ },
49
+ }),
50
+ },
51
+ ];
52
+ const user = await run(['gh', 'api', '--include', 'user'], {
53
+ timeoutMs: GITHUB_STEP_TIMEOUT_MS,
54
+ });
55
+ const response = parseResponse(user.stdout);
56
+ const login =
57
+ user.ok && typeof response.body?.['login'] === 'string'
58
+ ? response.body['login']
59
+ : null;
60
+ checks.push(principal(target, login));
61
+ if (target.managed) checks.push(await tokenCurrent(run, target, input));
62
+ if (login === null) return checks;
63
+ const now = (input.now ?? (() => new Date()))();
64
+ checks.push(
65
+ await permissions(run, policy.scopes, policy.repositories, response),
66
+ expiry(response.headers, now),
67
+ rateLimit(response.headers),
68
+ ...(await organizationAccess(run, policy.organizations, login)),
69
+ await gitCredential(run)
70
+ );
71
+ return checks;
72
+ }
73
+
74
+ interface Response {
75
+ headers: Record<string, string>;
76
+ body: Record<string, unknown> | null;
77
+ }
78
+
79
+ /** `gh api --include`: a status line, headers, a blank line, then the body. */
80
+ export function parseResponse(text: string): Response {
81
+ const [head = '', ...rest] = text.split(/\r?\n\r?\n/);
82
+ const headers: Record<string, string> = {};
83
+ for (const line of head.split(/\r?\n/).slice(1)) {
84
+ const at = line.indexOf(':');
85
+ if (at > 0)
86
+ headers[line.slice(0, at).trim().toLowerCase()] = line
87
+ .slice(at + 1)
88
+ .trim();
89
+ }
90
+ let body: Record<string, unknown> | null = null;
91
+ try {
92
+ const parsed: unknown = JSON.parse(rest.join('\n\n'));
93
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
94
+ body = parsed as Record<string, unknown>;
95
+ } catch {}
96
+ return { headers, body };
97
+ }
98
+
99
+ function principal(target: GithubNode, login: string | null): Check {
100
+ if (target.login === null)
101
+ return {
102
+ id: 'github:principal',
103
+ status: 'skip',
104
+ required: false,
105
+ summary:
106
+ 'The fleet declares no GitHub login (worker.githubLogin); the account is not compared.',
107
+ };
108
+ const ok = login === target.login;
109
+ return {
110
+ id: 'github:principal',
111
+ status: ok ? 'pass' : 'fail',
112
+ summary: ok
113
+ ? `This ${target.role} is signed in to GitHub as the declared ${target.managed ? 'fleet' : 'personal'} login.`
114
+ : login === null
115
+ ? 'GITHUB_NOT_SIGNED_IN'
116
+ : 'GITHUB_LOGIN_MISMATCH: this node is signed in to GitHub as an account the fleet configuration does not declare for it.',
117
+ ...(ok
118
+ ? {}
119
+ : {
120
+ remedy: {
121
+ action: target.managed
122
+ ? 'Run fleet-sync: it signs this node in with the token in the secrets provider (github-token). If the stored token belongs to another account, replace it or change worker.githubLogin.'
123
+ : 'Sign in on this manager as the login named in github.manager of the fleet configuration (gh auth login), or change that declaration. dev-fleet never replaces a personal login.',
124
+ },
125
+ }),
126
+ };
127
+ }
128
+
129
+ /** The node uses the token the store holds now: after a rotation, every node
130
+ * passes before the old token is revoked.
131
+ */
132
+ async function tokenCurrent(
133
+ run: Runner,
134
+ target: GithubNode,
135
+ input: GithubCheckInput
136
+ ): Promise<Check> {
137
+ const stored = (
138
+ (await githubCredential(input.definition, target.node, input.secret)()) ??
139
+ ''
140
+ ).trim();
141
+ if (!stored)
142
+ return {
143
+ id: 'github:token-current',
144
+ status: 'skip',
145
+ required: false,
146
+ summary:
147
+ 'The secrets provider holds no github-token for this node; there is no stored token to compare with.',
148
+ };
149
+ const ok = (await activeToken(run)) === stored;
150
+ return {
151
+ id: 'github:token-current',
152
+ status: ok ? 'pass' : 'fail',
153
+ summary: ok
154
+ ? 'This node signs GitHub requests with the token the store holds now.'
155
+ : 'GITHUB_TOKEN_STALE: this node still uses a token the store no longer holds.',
156
+ ...(ok
157
+ ? {}
158
+ : {
159
+ remedy: {
160
+ action:
161
+ 'Run fleet-sync, or wait for the next run of the GitHub sign-in job: either adopts the stored token while the old one still works. Revoke the old token only when every node passes this check.',
162
+ },
163
+ }),
164
+ };
165
+ }
166
+
167
+ /** A scope a token has implies the scopes beneath it. */
168
+ const IMPLIED: Record<string, string[]> = {
169
+ repo: [
170
+ 'repo:status',
171
+ 'repo_deployment',
172
+ 'public_repo',
173
+ 'repo:invite',
174
+ 'security_events',
175
+ ],
176
+ 'write:packages': ['read:packages'],
177
+ 'admin:org': ['write:org', 'read:org', 'manage_runners:org'],
178
+ 'write:org': ['read:org'],
179
+ 'admin:repo_hook': ['write:repo_hook', 'read:repo_hook'],
180
+ 'write:repo_hook': ['read:repo_hook'],
181
+ 'admin:public_key': ['write:public_key', 'read:public_key'],
182
+ 'write:public_key': ['read:public_key'],
183
+ user: ['read:user', 'user:email', 'user:follow'],
184
+ };
185
+
186
+ async function permissions(
187
+ run: Runner,
188
+ scopes: string[],
189
+ repositories: string[],
190
+ response: Response
191
+ ): Promise<Check> {
192
+ const base = { id: 'github:permissions' };
193
+ if (!scopes.length && !repositories.length)
194
+ return {
195
+ ...base,
196
+ status: 'skip',
197
+ required: false,
198
+ summary:
199
+ 'The fleet declares no GitHub scopes or repositories (github.scopes, github.repositories).',
200
+ };
201
+ const granted = response.headers['x-oauth-scopes'];
202
+ const missing: string[] = [];
203
+ if (scopes.length && granted !== undefined) {
204
+ const held = new Set(
205
+ granted
206
+ .split(',')
207
+ .map((scope) => scope.trim())
208
+ .filter(Boolean)
209
+ .flatMap((scope) => [scope, ...(IMPLIED[scope] ?? [])])
210
+ );
211
+ missing.push(...scopes.filter((scope) => !held.has(scope)));
212
+ }
213
+ for (const repository of repositories) {
214
+ const result = await run(
215
+ ['gh', 'api', `repos/${repository}`, '--jq', '.permissions.push'],
216
+ { timeoutMs: GITHUB_STEP_TIMEOUT_MS }
217
+ );
218
+ if (!result.ok || result.stdout.trim() !== 'true')
219
+ missing.push(`push to ${repository}`);
220
+ }
221
+ if (missing.length)
222
+ return {
223
+ ...base,
224
+ status: 'fail',
225
+ summary: `GITHUB_PERMISSION_MISSING: ${missing.join(', ')}`,
226
+ remedy: {
227
+ action:
228
+ 'Issue a token with the permissions named, store it as github-token and run fleet-sync. A token without them fails agents mid-task with a 403.',
229
+ },
230
+ };
231
+ // A fine-grained token reports no scopes: only the repositories are proof.
232
+ if (scopes.length && granted === undefined)
233
+ return {
234
+ ...base,
235
+ status: 'warn',
236
+ summary:
237
+ 'GITHUB_SCOPES_NOT_OBSERVABLE: the token reports no scopes (a fine-grained token); the declared repositories were checked.',
238
+ };
239
+ return {
240
+ ...base,
241
+ status: 'pass',
242
+ summary:
243
+ 'The token holds every declared scope and can push to every declared repository.',
244
+ };
245
+ }
246
+
247
+ /** `2026-11-20 12:00:00 UTC` or `2026-11-20 12:00:00 -0800`. */
248
+ export function parseExpiry(value: string): Date | null {
249
+ const match =
250
+ /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) (UTC|[+-]\d{4})$/.exec(value);
251
+ if (!match) return null;
252
+ const zone =
253
+ match[3] === 'UTC' ? 'Z' : `${match[3]!.slice(0, 3)}:${match[3]!.slice(3)}`;
254
+ const date = new Date(`${match[1]}T${match[2]}${zone}`);
255
+ return Number.isNaN(date.getTime()) ? null : date;
256
+ }
257
+
258
+ function expiry(headers: Record<string, string>, now: Date): Check {
259
+ const value = headers['github-authentication-token-expiration'];
260
+ const base = { id: 'github:expiry' };
261
+ if (value === undefined)
262
+ return {
263
+ ...base,
264
+ status: 'pass',
265
+ summary: 'GitHub reports no expiry date for this token.',
266
+ };
267
+ const date = parseExpiry(value);
268
+ if (!date)
269
+ return {
270
+ ...base,
271
+ status: 'warn',
272
+ summary:
273
+ 'GitHub reported an expiry date in a form this check does not read.',
274
+ };
275
+ const days = (date.getTime() - now.getTime()) / DAY;
276
+ const at = date.toISOString();
277
+ const remedy = {
278
+ action:
279
+ 'Issue a new token, store it as github-token and run fleet-sync before this one expires; every node adopts it while the old one still works.',
280
+ };
281
+ if (days <= EXPIRY_FAIL_DAYS)
282
+ return {
283
+ ...base,
284
+ status: 'fail',
285
+ summary: `GITHUB_TOKEN_EXPIRING: the token expires at ${at}.`,
286
+ remedy,
287
+ };
288
+ if (days <= EXPIRY_WARN_DAYS)
289
+ return {
290
+ ...base,
291
+ status: 'warn',
292
+ summary: `GITHUB_TOKEN_EXPIRING_SOON: the token expires at ${at}.`,
293
+ remedy,
294
+ };
295
+ return { ...base, status: 'pass', summary: `The token expires at ${at}.` };
296
+ }
297
+
298
+ function rateLimit(headers: Record<string, string>): Check {
299
+ const limit = Number(headers['x-ratelimit-limit']);
300
+ const remaining = Number(headers['x-ratelimit-remaining']);
301
+ const reset = Number(headers['x-ratelimit-reset']);
302
+ const base = { id: 'github:rate-limit' };
303
+ if (!(limit > 0) || !Number.isFinite(remaining))
304
+ return {
305
+ ...base,
306
+ status: 'skip',
307
+ required: false,
308
+ summary: 'GitHub reported no rate limit for this request.',
309
+ };
310
+ const percent = Math.round((remaining / limit) * 100);
311
+ const resetAt = Number.isFinite(reset)
312
+ ? new Date(reset * 1000).toISOString()
313
+ : 'an unknown time';
314
+ if (remaining / limit < RATE_WARN_FRACTION)
315
+ return {
316
+ ...base,
317
+ status: 'warn',
318
+ summary: `GITHUB_RATE_LIMIT_LOW: ${remaining} of ${limit} requests (${percent} %) remain until ${resetAt}.`,
319
+ remedy: {
320
+ action:
321
+ 'Every node shares the rate limit of one login. Spread the load with per-node credentials (github.credential "per-node") or wait for the reset.',
322
+ },
323
+ };
324
+ return {
325
+ ...base,
326
+ status: 'pass',
327
+ summary: `${remaining} of ${limit} requests (${percent} %) remain until ${resetAt}.`,
328
+ };
329
+ }
330
+
331
+ async function organizationAccess(
332
+ run: Runner,
333
+ organizations: string[],
334
+ login: string
335
+ ): Promise<Check[]> {
336
+ if (!organizations.length) return [];
337
+ const missing: string[] = [];
338
+ const unauthorized: string[] = [];
339
+ for (const organization of organizations) {
340
+ const result = await run(
341
+ ['gh', 'api', '--include', `user/memberships/orgs/${organization}`],
342
+ { timeoutMs: GITHUB_STEP_TIMEOUT_MS }
343
+ );
344
+ const response = parseResponse(result.stdout);
345
+ if (response.headers['x-github-sso']?.startsWith('required'))
346
+ unauthorized.push(organization);
347
+ else if (!result.ok || response.body?.['state'] !== 'active')
348
+ missing.push(organization);
349
+ }
350
+ if (!missing.length && !unauthorized.length)
351
+ return [
352
+ {
353
+ id: 'github:org-access',
354
+ status: 'pass',
355
+ summary: `${login} is an active member of every declared organisation, and single sign-on has authorised the token.`,
356
+ },
357
+ ];
358
+ const reasons: string[] = [];
359
+ if (unauthorized.length)
360
+ reasons.push(
361
+ `GITHUB_SSO_AUTHORIZATION_REQUIRED: ${unauthorized.join(', ')}`
362
+ );
363
+ if (missing.length)
364
+ reasons.push(`GITHUB_ORG_MEMBERSHIP_MISSING: ${missing.join(', ')}`);
365
+ return [
366
+ {
367
+ id: 'github:org-access',
368
+ status: 'fail',
369
+ summary: reasons.join('; '),
370
+ remedy: {
371
+ action:
372
+ 'Authorise the token for single sign-on in each organisation named (GitHub settings, Configure SSO), or add the fleet login to it. This needs a person who administers the organisation.',
373
+ },
374
+ },
375
+ ];
376
+ }
377
+
378
+ /** git asks gh for the token at every use, so a replaced token needs no
379
+ * change in git; no second copy of it is written.
380
+ */
381
+ async function gitCredential(run: Runner): Promise<Check> {
382
+ const helpers = await run(
383
+ [
384
+ 'git',
385
+ 'config',
386
+ '--global',
387
+ '--get-all',
388
+ 'credential.https://github.com.helper',
389
+ ],
390
+ { timeoutMs: 5_000 }
391
+ );
392
+ const ok =
393
+ helpers.ok &&
394
+ helpers.stdout
395
+ .split('\n')
396
+ .some((line) => /(^|[/!\s])gh'? auth git-credential$/.test(line.trim()));
397
+ return {
398
+ id: 'github:git-credential',
399
+ status: ok ? 'pass' : 'fail',
400
+ summary: ok
401
+ ? 'git asks gh for the GitHub token at every use.'
402
+ : 'GITHUB_GIT_CREDENTIAL_MISSING: git does not take its GitHub credential from gh.',
403
+ ...(ok
404
+ ? {}
405
+ : {
406
+ remedy: {
407
+ action:
408
+ 'Declare the git block in fleet.json and run fleet-sync: ansible/github/git.yml sets gh as the credential helper. On a manager with a personal login, run gh auth setup-git.',
409
+ },
410
+ }),
411
+ };
412
+ }
@@ -0,0 +1,62 @@
1
+ import type { Check, Runner } from '../types';
2
+ import type { GithubCode } from './sync';
3
+
4
+ const CODES: GithubCode[] = [
5
+ 'GITHUB_OK',
6
+ 'GITHUB_TOKEN_MISSING',
7
+ 'GITHUB_TOKEN_REJECTED',
8
+ 'GITHUB_LOGIN_MISMATCH',
9
+ 'GITHUB_UNREACHABLE',
10
+ 'GITHUB_SIGN_IN_FAILED',
11
+ ];
12
+
13
+ const REMEDIES: Record<Exclude<GithubCode, 'GITHUB_OK'>, string> = {
14
+ GITHUB_TOKEN_MISSING:
15
+ 'Store the fleet GitHub token in the secrets provider under github-token (github-token-<node> with github.credential "per-node"), then run fleet-sync again.',
16
+ GITHUB_TOKEN_REJECTED:
17
+ 'GitHub refuses the stored token: it was revoked or has expired. Store a new one under github-token and run fleet-sync again.',
18
+ GITHUB_LOGIN_MISMATCH:
19
+ 'The stored token belongs to another account than the fleet configuration declares (worker.githubLogin, or github.manager for the manager). Store the right token or correct the declaration.',
20
+ GITHUB_UNREACHABLE:
21
+ 'GitHub did not answer from this node. Check its network and DNS; the node keeps the sign-in it has.',
22
+ GITHUB_SIGN_IN_FAILED:
23
+ 'gh refused to store the validated token. Inspect gh on this node (gh auth status); nothing was copied from another machine.',
24
+ };
25
+
26
+ /** Runs this node's GitHub sign-in job once and reports what it came to. A
27
+ * failure fails the sync: a node that cannot reach GitHub as the fleet login
28
+ * is not ready for an agent.
29
+ */
30
+ export async function convergeGithub(
31
+ root: string,
32
+ run: Runner,
33
+ node?: string
34
+ ): Promise<Check> {
35
+ const result = await run(
36
+ ['bun', 'bootstrap/worker-github.ts', ...(node ? ['--node', node] : [])],
37
+ { cwd: root, timeoutMs: 240_000 }
38
+ );
39
+ let code: GithubCode | undefined;
40
+ try {
41
+ const line: unknown = JSON.parse(result.stdout.trim().split('\n').pop()!);
42
+ const found = (line as { code?: unknown }).code;
43
+ if (CODES.includes(found as GithubCode)) code = found as GithubCode;
44
+ } catch {}
45
+ if (code === 'GITHUB_OK')
46
+ return {
47
+ id: 'github:sync',
48
+ status: 'pass',
49
+ summary:
50
+ 'This node is signed in to GitHub as its declared login with the stored credential.',
51
+ };
52
+ return {
53
+ id: 'github:sync',
54
+ status: 'fail',
55
+ summary: code ?? 'GITHUB_SYNC_UNREPORTED',
56
+ remedy: {
57
+ action: code
58
+ ? REMEDIES[code]
59
+ : 'The GitHub sign-in job reported no result. Run bun bootstrap/worker-github.ts on this node to see its result line.',
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,54 @@
1
+ import type { Check, Runner } from '../types';
2
+ import { activeToken } from './sync';
3
+
4
+ /** The kinds of GitHub token Copilot CLI accepts: OAuth (`gho_`), GitHub App
5
+ * user (`ghu_`) and fine-grained personal (`github_pat_`) tokens. A classic
6
+ * personal token (`ghp_`) is refused by Copilot.
7
+ */
8
+ const ACCEPTED = /^(gho_|ghu_|github_pat_)/;
9
+ const CLASSIC = /^ghp_/;
10
+
11
+ /** Copilot CLI signs in with the node's GitHub credential: with no token of
12
+ * its own in the environment and no `copilot login`, it uses the token gh
13
+ * holds. So the node's GitHub sign-in is Copilot's, and nothing is signed in
14
+ * per node by hand. Only the kind of the token is read, never the token.
15
+ */
16
+ export async function copilotCheck(run: Runner): Promise<Check> {
17
+ const installed = await run(['copilot', '--version'], { timeoutMs: 15_000 });
18
+ if (!installed.ok)
19
+ return {
20
+ id: 'auth:copilot',
21
+ status: 'skip',
22
+ required: false,
23
+ summary: 'Copilot CLI is not installed in this process context.',
24
+ };
25
+ const token = await activeToken(run, 15_000);
26
+ if (token === null)
27
+ return {
28
+ id: 'auth:copilot',
29
+ status: 'fail',
30
+ summary:
31
+ 'not-signed-in: this node holds no GitHub credential for Copilot CLI to use.',
32
+ remedy: {
33
+ action:
34
+ 'Run fleet-sync: it signs the node in to GitHub from the stored credential, and Copilot CLI uses that sign-in. No copilot login is needed.',
35
+ },
36
+ };
37
+ if (CLASSIC.test(token) || !ACCEPTED.test(token))
38
+ return {
39
+ id: 'auth:copilot',
40
+ status: 'fail',
41
+ summary:
42
+ 'COPILOT_TOKEN_UNSUPPORTED: the node’s GitHub token is of a kind Copilot CLI refuses (a classic personal access token).',
43
+ remedy: {
44
+ action:
45
+ 'Store a fine-grained token with the Copilot Requests permission as github-token and run fleet-sync; every node and Copilot CLI then use it.',
46
+ },
47
+ };
48
+ return {
49
+ id: 'auth:copilot',
50
+ status: 'pass',
51
+ summary:
52
+ 'Copilot CLI uses this node’s GitHub sign-in, whose token is of a kind Copilot accepts. Copilot entitlement of the account is not measured here; no prompt was made.',
53
+ };
54
+ }