@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.
package/src/secrets.ts CHANGED
@@ -12,10 +12,13 @@ export const SECRET_NAMES = [
12
12
  'monitoring-tunnel-token',
13
13
  'proxy-tunnel-token',
14
14
  ] as const;
15
- /** Secrets only a feature the fleet declares needs: a Vault location may
16
- * leave them out, and they are then unavailable.
15
+ /** Secrets a fleet may leave undeclared. A Vault fleet that names no location
16
+ * for the GitHub token keeps it where dev-fleet always read it (see
17
+ * `vaultLocation`); the others are needed only by a feature the fleet
18
+ * declares, and are otherwise unavailable.
17
19
  */
18
20
  export const OPTIONAL_SECRET_NAMES = [
21
+ 'github-token',
19
22
  // The off-machine storage of the monitoring hub's backups
20
23
  // (services.monitoring.backups).
21
24
  'monitoring-backup-access-key',
@@ -52,16 +55,53 @@ export type SecretCommand = (
52
55
  ) => Promise<string | null>;
53
56
 
54
57
  /** One read of one secret for this node. Null when it is not available; the
55
- * value is never logged, printed or placed in argv.
58
+ * value is never logged, printed or placed in argv. `node` names a node's own
59
+ * copy of a secret that is kept once per node (`secretVariant`).
56
60
  */
57
- export type SecretsProvider = (name: SecretName) => Promise<string | null>;
61
+ export type SecretsProvider = (
62
+ name: SecretName,
63
+ node?: string
64
+ ) => Promise<string | null>;
58
65
 
59
66
  const present = (value: string | null | undefined): string | null =>
60
67
  value?.trim() ? value.trim() : null;
61
68
 
62
69
  /** `proxy-client-key` is read from `DEVFLEET_SECRET_PROXY_CLIENT_KEY`. */
63
- export const envName = (name: SecretName): string =>
64
- `DEVFLEET_SECRET_${name.toUpperCase().replaceAll('-', '_')}`;
70
+ export const envName = (name: SecretName, node?: string): string =>
71
+ `DEVFLEET_SECRET_${secretVariant(name, node).toUpperCase().replaceAll('-', '_')}`;
72
+
73
+ /** The name of a node's own copy of a secret: `github-token` of `worker-one`
74
+ * is `github-token-worker-one` (`DEVFLEET_SECRET_GITHUB_TOKEN_WORKER_ONE`, the
75
+ * file or sops key `github-token-worker-one`, the Vault field `FIELD_WORKER_ONE`).
76
+ */
77
+ export const secretVariant = (name: SecretName, node?: string): string =>
78
+ node ? `${name}-${node}` : name;
79
+
80
+ /** Where a Vault fleet keeps a secret. The GitHub token was always read from
81
+ * the field `GITHUB_TOKEN` beside the proxy client key; a fleet that names no
82
+ * location of its own keeps it there.
83
+ */
84
+ export function vaultLocation(
85
+ config: Extract<SecretsConfig, { provider: 'vault' }>,
86
+ name: SecretName,
87
+ node?: string
88
+ ): VaultLocation | null {
89
+ const location =
90
+ config.locations[name as keyof typeof config.locations] ??
91
+ (name === 'github-token'
92
+ ? {
93
+ path: config.locations['proxy-client-key'].path,
94
+ field: 'GITHUB_TOKEN',
95
+ }
96
+ : null);
97
+ if (!location) return null;
98
+ return node
99
+ ? {
100
+ path: location.path,
101
+ field: `${location.field}_${node.toUpperCase().replaceAll('-', '_')}`,
102
+ }
103
+ : location;
104
+ }
65
105
 
66
106
  export function secretsProvider(
67
107
  config: SecretsConfig,
@@ -74,29 +114,34 @@ export function secretsProvider(
74
114
  const env = options.env ?? process.env;
75
115
  const run = options.run ?? execute;
76
116
  if (config.provider === 'env')
77
- return async (name) => present(env[envName(name)]);
117
+ return async (name, node) => present(env[envName(name, node)]);
78
118
  if (config.provider === 'file') {
79
119
  // One private file per secret, named after it.
80
120
  const directory =
81
121
  config.directory ??
82
122
  join(options.home ?? homedir(), '.config/dev-fleet/secrets');
83
- return async (name) =>
84
- present(await readFile(join(directory, name), 'utf8').catch(() => null));
123
+ return async (name, node) =>
124
+ present(
125
+ await readFile(
126
+ join(directory, secretVariant(name, node)),
127
+ 'utf8'
128
+ ).catch(() => null)
129
+ );
85
130
  }
86
131
  if (config.provider === 'sops')
87
132
  // An age- (or KMS-) encrypted document with one top-level key per secret.
88
- return async (name) =>
133
+ return async (name, node) =>
89
134
  present(
90
135
  await run([
91
136
  'sops',
92
137
  '--decrypt',
93
138
  '--extract',
94
- `["${name}"]`,
139
+ `["${secretVariant(name, node)}"]`,
95
140
  config.file,
96
141
  ])
97
142
  );
98
- return async (name) => {
99
- const location = config.locations[name];
143
+ return async (name, node) => {
144
+ const location = vaultLocation(config, name, node);
100
145
  if (!location) return null;
101
146
  return present(
102
147
  await run([
@@ -1,4 +1,5 @@
1
1
  import { record } from './config';
2
+ import { copilotCheck } from './github/copilot';
2
3
  import type { Check, CommandResult, Runner } from './types';
3
4
 
4
5
  interface Service {
@@ -48,8 +49,6 @@ export async function codingAuth(
48
49
  export const PROXY_ROUTED: Record<string, string> = {
49
50
  gemini:
50
51
  'gemini signs in through the CLI proxy and has no T3 launcher; provider:gemini:inference proves its route from this node.',
51
- copilot:
52
- 'copilot is not routed through the CLI proxy and dev-fleet does not manage its GitHub sign-in; no provider account is checked.',
53
52
  opencode:
54
53
  'opencode signs in through the CLI proxy; agent:muse:session and the per-account agent:opencode sessions prove it from this node.',
55
54
  };
@@ -101,6 +100,7 @@ export async function serviceAuth(run: Runner): Promise<Check[]> {
101
100
  ];
102
101
  const checks: Check[] = [];
103
102
  for (const service of services) checks.push(await serviceCheck(service, run));
103
+ checks.push(await copilotCheck(run));
104
104
  for (const [id, reason] of Object.entries(PROXY_ROUTED))
105
105
  checks.push({
106
106
  id: `auth:${id}`,
@@ -4,29 +4,79 @@ import { secretsProvider } from './secrets';
4
4
  import { registry } from './agents/registry';
5
5
  import { resolveVaultConfig, usesVault } from './vault-config';
6
6
  import { record } from './config';
7
+ import { githubCredential } from './github/credential';
8
+ import { hostId } from './topology';
9
+ import { hostname } from 'node:os';
10
+ import { githubSignInState } from './github/sync';
7
11
 
8
12
  /** Inject every subprocess; never put credentials in argv, reports, or logs. */
9
- export async function workerAccessChecks(run: Runner): Promise<Check[]> {
10
- const policy = fleet.worker!;
11
- const github = await run(['gh', 'api', 'user', '--jq', '.login']);
13
+ export async function workerAccessChecks(
14
+ run: Runner,
15
+ nodeId?: string
16
+ ): Promise<Check[]> {
12
17
  return [
13
- {
14
- id: 'worker:github-principal',
15
- status:
16
- github.ok && github.stdout.trim() === policy.githubLogin
17
- ? 'pass'
18
- : 'fail',
19
- summary:
20
- 'Worker GitHub authentication must match the declared common account.',
21
- remedy: {
22
- action:
23
- 'Authenticate this worker with the declared GitHub account. Credentials stay on their original machines.',
24
- },
25
- },
18
+ await githubSync(run, nodeId),
26
19
  await proxyAuthentication(run),
27
20
  ...(usesVault() ? [await vaultRenewalCheck(run)] : []),
28
21
  ];
29
22
  }
23
+
24
+ const GITHUB_REMEDIES: Record<string, string> = {
25
+ GITHUB_TOKEN_MISSING:
26
+ 'The secrets provider holds no GitHub token for this worker. Store it under github-token (github-token-<node> with github.credential "per-node"); the next sign-in job or fleet-sync signs the worker in.',
27
+ GITHUB_TOKEN_REJECTED:
28
+ 'GitHub refuses the stored token: it was revoked or has expired. Store a new one under github-token; nothing about the secret store itself needs fixing.',
29
+ GITHUB_LOGIN_MISMATCH:
30
+ 'The stored token belongs to another account than worker.githubLogin. Store the fleet login’s token, or correct worker.githubLogin in the fleet configuration.',
31
+ GITHUB_UNREACHABLE:
32
+ 'GitHub did not answer from this worker. Check its network and DNS; the worker keeps the sign-in it has.',
33
+ GITHUB_SIGN_IN_FAILED:
34
+ 'The stored token is valid but this worker has not adopted it. Inspect the job with: journalctl --user -u personal-worker-github.service, or run fleet-sync.',
35
+ };
36
+
37
+ /** GitHub sign-in is measured on its own, so a GitHub fault is never reported
38
+ * as a fault of the secret store's renewal, and the code names the system to
39
+ * fix.
40
+ */
41
+ async function githubSync(run: Runner, nodeId?: string): Promise<Check> {
42
+ const login = fleet.worker?.githubLogin;
43
+ if (!login)
44
+ return {
45
+ id: 'worker:github-sync',
46
+ status: 'fail',
47
+ summary: 'GITHUB_LOGIN_UNDECLARED',
48
+ remedy: {
49
+ action: 'Declare the fleet GitHub login as worker.githubLogin.',
50
+ },
51
+ };
52
+ const code = await githubSignInState({
53
+ run,
54
+ login,
55
+ token: githubCredential(fleet, nodeId ?? hostId(hostname()), secrets(run)),
56
+ });
57
+ return code === 'GITHUB_OK'
58
+ ? {
59
+ id: 'worker:github-sync',
60
+ status: 'pass',
61
+ summary:
62
+ 'Worker GitHub authentication matches the declared common account.',
63
+ }
64
+ : {
65
+ id: 'worker:github-sync',
66
+ status: 'fail',
67
+ summary: code,
68
+ remedy: { action: GITHUB_REMEDIES[code]! },
69
+ };
70
+ }
71
+
72
+ const secrets = (run: Runner) =>
73
+ secretsProvider(fleet.secrets, {
74
+ run: async (argv) => {
75
+ const result = await run(argv, { timeoutMs: 20_000 });
76
+ return result.ok ? result.stdout : null;
77
+ },
78
+ });
79
+
30
80
  /** Vault refuses a read the token's policy does not allow with a 403. */
31
81
  const PERMISSION_DENIED = /permission denied|Code: 403/i;
32
82
 
@@ -16,7 +16,7 @@ export async function workerReadiness(
16
16
  root = process.cwd()
17
17
  ): Promise<Check[]> {
18
18
  const host = await workerHostChecks(userInfo().username, run);
19
- const access = await workerAccessChecks(run);
19
+ const access = await workerAccessChecks(run, nodeId);
20
20
  const revision = await run(['git', 'rev-parse', 'HEAD']);
21
21
  const singleton = await run(
22
22
  [