@crvouga/dev-fleet 0.5.0 → 0.6.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.
@@ -6,6 +6,8 @@ import { codingAuth, serviceAuth } from './service-auth';
6
6
  import { workstationChecks } from './workstation-checks';
7
7
  import { toolchainChecks } from './toolchain-checks';
8
8
  import { workerSelection } from '../bootstrap/worker-tools';
9
+ import { githubChecks } from './github/checks';
10
+ import { secretsProvider } from './secrets';
9
11
  import type { Capability, Check, Runner, CommandResult } from './types';
10
12
 
11
13
  export async function authCheck(
@@ -20,6 +22,17 @@ export async function authCheck(
20
22
  return codingAuth(capability, run);
21
23
  if (capability === 'services') return serviceAuth(run);
22
24
  if (capability === 'workstation') return workstationChecks(root, run);
25
+ if (capability === 'github')
26
+ return githubChecks(run, {
27
+ definition,
28
+ ...(nodeId === undefined ? {} : { node: nodeId }),
29
+ secret: secretsProvider(definition.secrets, {
30
+ run: async (argv) => {
31
+ const result = await run(argv, { timeoutMs: 20_000 });
32
+ return result.ok ? result.stdout : null;
33
+ },
34
+ }),
35
+ });
23
36
  if (capability === 'toolchain')
24
37
  return toolchainChecks(
25
38
  root,
@@ -32,16 +45,11 @@ export async function authCheck(
32
45
  }
33
46
 
34
47
  async function basicAuth(
35
- capability: 'github' | 'vault' | 'vscode',
48
+ capability: 'vault' | 'vscode',
36
49
  run: Runner,
37
50
  root: string
38
51
  ): Promise<Check[]> {
39
52
  const spec = {
40
- github: {
41
- command: ['gh', 'auth', 'status', '--active'],
42
- action:
43
- 'Authenticate on this node with gh auth login, then gh auth setup-git.',
44
- },
45
53
  vault: {
46
54
  command: [
47
55
  'vault',
@@ -16,6 +16,7 @@ import { idesOf } from '../ide/definition';
16
16
  import { convergeGitIdentity, gitChecks } from '../git-identity';
17
17
  import macos from '../../ansible/macos/policy.json';
18
18
  import { ansibleCommand } from '../platform/ansible';
19
+ import { convergeGithub } from '../github/converge';
19
20
  import { PACKAGE_ROOT } from '../package-info';
20
21
  import { managerHealth } from '../manager-health';
21
22
  import { readFileSync } from 'node:fs';
@@ -70,6 +71,14 @@ export async function convergeManager(
70
71
  );
71
72
  if (definition.git)
72
73
  checks.push(...(await gitChecks(definition.git, run)));
74
+ // The same GitHub sign-in as every worker, or only a check of a
75
+ // person's own login when the configuration names one. git asks gh
76
+ // for the token through the helper git.yml declared above.
77
+ checks.push(
78
+ ...(await attempt('github:sync', async () => [
79
+ await convergeGithub(input.workspace, run),
80
+ ]))
81
+ );
73
82
  const providers = await attempt('provider:verification', () =>
74
83
  verifyFleetProviders(id)
75
84
  );
@@ -0,0 +1,57 @@
1
+ import type { Command } from 'commander';
2
+ import { hostname } from 'node:os';
3
+ import { FleetUsageError } from '../errors';
4
+ import { fleet } from '../fleet-config';
5
+ import { githubPolicy } from '../github/policy';
6
+ import { verifyGithubWorkflow } from '../github/workflow';
7
+ import { runCommand } from '../runner';
8
+ import { hostId } from '../topology';
9
+ import type { Runner } from '../types';
10
+ import type { CommandOutcome } from './contracts';
11
+
12
+ /** Runs the GitHub workflow an agent depends on from this machine against
13
+ * the declared sandbox repository, and cleans up after itself.
14
+ */
15
+ export async function githubVerify(
16
+ run: Runner = runCommand,
17
+ node = hostId(hostname())
18
+ ): Promise<CommandOutcome> {
19
+ const sandbox = githubPolicy(fleet).sandbox;
20
+ if (!sandbox)
21
+ throw new FleetUsageError(
22
+ 'No sandbox repository. Declare github.sandbox ("owner/name") in fleet.json: a repository the fleet login may branch, push to and open pull requests on.'
23
+ );
24
+ const result = await verifyGithubWorkflow({ run, sandbox, node });
25
+ const ok = result.check.status === 'pass';
26
+ return {
27
+ exitCode: ok ? 0 : 1,
28
+ data: {
29
+ version: 1,
30
+ command: 'github-verify',
31
+ ok,
32
+ node,
33
+ checks: [result.check],
34
+ ...(result.failed ? { failedStep: result.failed } : {}),
35
+ leftBehind: result.leftBehind,
36
+ },
37
+ text: `${result.check.id} ${result.check.status}: ${result.check.summary}`,
38
+ };
39
+ }
40
+
41
+ export function registerGithubVerify(
42
+ program: Command,
43
+ complete: (outcome: CommandOutcome) => void
44
+ ): void {
45
+ program
46
+ .command('fleet-github-verify')
47
+ .description(
48
+ 'Prove this machine can clone, push, open a pull request, read checks and comment on GitHub'
49
+ )
50
+ .addHelpText(
51
+ 'after',
52
+ '\nWorks on the sandbox repository named by github.sandbox in fleet.json: a uniquely named branch and a draft pull request, both closed and deleted again by the same run, whatever failed.'
53
+ )
54
+ .action(async () => {
55
+ complete(await githubVerify());
56
+ });
57
+ }
@@ -11,6 +11,7 @@ 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';
14
15
 
15
16
  type Complete = (outcome: CommandOutcome) => void;
16
17
 
@@ -350,6 +351,7 @@ export function registerFleet(
350
351
  registerRepair(registration);
351
352
  registerNodes(registration);
352
353
  registerAccounts(registration);
354
+ registerGithubVerify(program, complete);
353
355
  }
354
356
 
355
357
  function registerAccounts({ program, defaults, complete }: Registration) {
@@ -161,6 +161,13 @@ export const COMMANDS: CommandSpec[] = [
161
161
  ['accounts', 'login', 'relogin', 'remove', 'add', 'sign in'],
162
162
  true,
163
163
  ],
164
+ [
165
+ 'fleet-github-verify',
166
+ 'Verify the GitHub workflow from this machine',
167
+ '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.',
168
+ ['github', 'verify', 'pull request', 'push', 'permissions', 'workflow'],
169
+ true,
170
+ ],
164
171
  ]),
165
172
  ...group('T3', [
166
173
  [
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
+ }