buildhive-agent 1.0.0-beta.10 → 1.0.0-beta.11

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.
@@ -2,15 +2,43 @@
2
2
  * `buildhive-agent join <token>` implementation.
3
3
  *
4
4
  * Row 17c — zero-GH developer onboarding (Wave A).
5
+ * Group B / S-1 — `join` now auto-installs the macOS LaunchAgent so the
6
+ * dev never has to think about persistence (two-commands install promise).
5
7
  * Design: docs/ops/zero-github-dev-onboarding-design-2026-05-17.md §3.1
8
+ * docs/ops/buildhive-reinit-fix-plan-2026-05-28.html §Group B (S-1)
6
9
  *
7
10
  * Exit-code contract (consumed by row 17b's `start` subcommand):
8
11
  * 0 — success, JWT stored in OS keyring
9
12
  * 1 — token rejected by backend (invalid / expired / consumed / revoked)
10
13
  * 2 — network error (cannot reach BuildHive)
11
14
  * 3 — keyring write failed
15
+ *
16
+ * Note: a LaunchAgent install failure does NOT change the exit code. The
17
+ * agent is fully enrolled at that point; the developer can re-run
18
+ * `buildhive-agent service:install` themselves and still pick up jobs
19
+ * from the foreground (`buildhive-agent start`) in the meantime.
12
20
  */
13
21
  import { AgentEnrollmentKeyringStore } from './agentEnrollmentKeyringStore.js';
22
+ import type { ServicePaths } from '../service/paths.js';
23
+ /**
24
+ * Service-install dependency surface — exposed for test injection so we
25
+ * don't reach into the real launchctl binary from unit tests.
26
+ *
27
+ * Mirrors the public shape of {@link installService} from
28
+ * `../service/serviceInstaller.ts`. When `serviceInstaller` is omitted,
29
+ * the production import is used.
30
+ */
31
+ export interface ServiceInstallerInjection {
32
+ readonly installService: (opts: {
33
+ mode: 'user' | 'system';
34
+ cliEntryPath: string;
35
+ label?: string;
36
+ homeDir?: string;
37
+ }) => Promise<{
38
+ paths: ServicePaths;
39
+ bootstrapStdout: string;
40
+ }>;
41
+ }
14
42
  export interface JoinOptions {
15
43
  readonly token: string;
16
44
  readonly platformUrl: string;
@@ -18,10 +46,39 @@ export interface JoinOptions {
18
46
  readonly store?: ConstructorParameters<typeof AgentEnrollmentKeyringStore>[0];
19
47
  /** Override fetch for tests. */
20
48
  readonly fetchFn?: typeof fetch;
49
+ /**
50
+ * S-1: opt-out of the post-enroll LaunchAgent install (e.g. when the
51
+ * caller is running the agent in a container or build-farm context that
52
+ * manages its own supervision). Default: false → install runs.
53
+ */
54
+ readonly skipServiceInstall?: boolean;
55
+ /**
56
+ * Dependency injection for tests (avoids real launchctl bootstrap).
57
+ * When omitted, the real `service/serviceInstaller.installService` is
58
+ * used via dynamic import.
59
+ */
60
+ readonly serviceInstaller?: ServiceInstallerInjection;
61
+ /**
62
+ * Override platform detection — tests set this to `'darwin'` regardless
63
+ * of the host OS so the LaunchAgent install path runs deterministically.
64
+ */
65
+ readonly platformOverride?: NodeJS.Platform;
66
+ /**
67
+ * Override the resolved CLI entry path. Tests set this so the service
68
+ * installer doesn't try to realpath `process.argv[1]` (which points at
69
+ * the test runner, not at dist/cli.js).
70
+ */
71
+ readonly cliEntryPathOverride?: string;
21
72
  }
22
73
  export interface JoinResult {
23
74
  readonly exitCode: 0 | 1 | 2 | 3;
24
75
  readonly message?: string;
76
+ /**
77
+ * S-1: whether the post-enroll LaunchAgent install was attempted and
78
+ * succeeded. Surfaced in the return for test assertions; the exit code
79
+ * is unchanged on install failure (the agent is still enrolled).
80
+ */
81
+ readonly serviceInstalled?: boolean;
25
82
  }
26
83
  /**
27
84
  * The main join logic. Exported for unit testing.
@@ -2,16 +2,24 @@
2
2
  * `buildhive-agent join <token>` implementation.
3
3
  *
4
4
  * Row 17c — zero-GH developer onboarding (Wave A).
5
+ * Group B / S-1 — `join` now auto-installs the macOS LaunchAgent so the
6
+ * dev never has to think about persistence (two-commands install promise).
5
7
  * Design: docs/ops/zero-github-dev-onboarding-design-2026-05-17.md §3.1
8
+ * docs/ops/buildhive-reinit-fix-plan-2026-05-28.html §Group B (S-1)
6
9
  *
7
10
  * Exit-code contract (consumed by row 17b's `start` subcommand):
8
11
  * 0 — success, JWT stored in OS keyring
9
12
  * 1 — token rejected by backend (invalid / expired / consumed / revoked)
10
13
  * 2 — network error (cannot reach BuildHive)
11
14
  * 3 — keyring write failed
15
+ *
16
+ * Note: a LaunchAgent install failure does NOT change the exit code. The
17
+ * agent is fully enrolled at that point; the developer can re-run
18
+ * `buildhive-agent service:install` themselves and still pick up jobs
19
+ * from the foreground (`buildhive-agent start`) in the meantime.
12
20
  */
13
21
  import os from 'os';
14
- import { readFileSync } from 'fs';
22
+ import { readFileSync, realpathSync } from 'fs';
15
23
  import { join, dirname } from 'path';
16
24
  import { fileURLToPath } from 'node:url';
17
25
  import { AgentEnrollmentKeyringStore } from './agentEnrollmentKeyringStore.js';
@@ -185,12 +193,90 @@ export async function runJoin(opts) {
185
193
  }
186
194
  return { exitCode: 3, message: `Could not store agent credentials in OS keyring: ${err instanceof Error ? err.message : err}` };
187
195
  }
188
- // 8. Print success message.
196
+ // 8. Print enrollment success.
189
197
  const agentIdShort = agentId.slice(0, 8);
190
198
  console.log(`✓ Agent enrolled in team "${teamName}" as agent ${agentIdShort}…`);
191
199
  console.log(`✓ JWT stored in OS keyring (expires ${expiresDate}).`);
192
- console.log('Run `buildhive-agent start` to begin picking up workflow jobs.');
193
- return { exitCode: 0 };
200
+ // 9. S-1: Auto-install LaunchAgent so the dev never thinks about persistence.
201
+ // Idempotent re-running `join` cleanly upgrades the plist (the underlying
202
+ // installer uses an atomic tmp-file → rename).
203
+ const serviceInstalled = await maybeInstallLaunchAgent(opts);
204
+ if (serviceInstalled) {
205
+ console.log('✓ Agent installed and will auto-start on every login. ' +
206
+ 'Inspect logs with `buildhive-agent logs`.');
207
+ }
208
+ else {
209
+ // Either non-macOS, opt-out, or install failed. Fall back to the
210
+ // foreground-start guidance the user can still execute.
211
+ console.log('Run `buildhive-agent start` to begin picking up workflow jobs.');
212
+ }
213
+ return { exitCode: 0, serviceInstalled };
214
+ }
215
+ /**
216
+ * S-1 helper: install the macOS LaunchAgent after a successful enrollment.
217
+ *
218
+ * Returns true if the service was installed (and is loaded into launchd),
219
+ * false otherwise. Never throws — a service-install failure is non-fatal
220
+ * for the join itself (the user can still run `buildhive-agent start`
221
+ * in the foreground while diagnosing).
222
+ */
223
+ async function maybeInstallLaunchAgent(opts) {
224
+ if (opts.skipServiceInstall === true) {
225
+ return false;
226
+ }
227
+ const platform = opts.platformOverride ?? process.platform;
228
+ if (platform !== 'darwin') {
229
+ // LaunchAgent is macOS-only. Linux/Windows fall through to the
230
+ // foreground-start path without an error.
231
+ return false;
232
+ }
233
+ // Resolve the CLI entry path so launchd's ProgramArguments points at
234
+ // an absolute path that survives PATH changes (e.g. nvm version switch).
235
+ let cliEntryPath;
236
+ try {
237
+ cliEntryPath = opts.cliEntryPathOverride ?? realpathSync(process.argv[1] ?? '');
238
+ }
239
+ catch {
240
+ console.error('[join] Could not resolve the CLI entry path for service install. ' +
241
+ 'Run `buildhive-agent service:install --cli-entry <abs-path>` manually.');
242
+ return false;
243
+ }
244
+ if (!cliEntryPath || !cliEntryPath.startsWith('/')) {
245
+ console.error('[join] Resolved CLI entry path is not absolute; skipping service install. ' +
246
+ 'Run `buildhive-agent service:install --cli-entry <abs-path>` manually.');
247
+ return false;
248
+ }
249
+ // Dynamic import so test environments without the service files (or
250
+ // running on platforms where launchctl isn't available) don't pay the
251
+ // load cost. Tests inject a fake via opts.serviceInstaller.
252
+ let installFn;
253
+ if (opts.serviceInstaller) {
254
+ installFn = opts.serviceInstaller.installService;
255
+ }
256
+ else {
257
+ try {
258
+ const mod = await import('../service/serviceInstaller.js');
259
+ installFn = mod.installService;
260
+ }
261
+ catch (err) {
262
+ console.error('[join] Could not load service installer:', err instanceof Error ? err.message : err);
263
+ return false;
264
+ }
265
+ }
266
+ try {
267
+ await installFn({ mode: 'user', cliEntryPath });
268
+ return true;
269
+ }
270
+ catch (err) {
271
+ // Already-loaded-service is a benign case — installService's atomic
272
+ // tmp-file-rename + launchctl bootstrap is idempotent, but a launchctl
273
+ // bootstrap on an already-loaded service may emit a non-zero exit. Surface
274
+ // the error to the user but do not change the enrollment exit code.
275
+ console.error('[join] LaunchAgent install failed (agent is still enrolled — JWT is in the keyring):', err instanceof Error ? err.message : err);
276
+ console.error('[join] You can finish setup manually with `buildhive-agent service:install` ' +
277
+ 'or run the agent in the foreground via `buildhive-agent start`.');
278
+ return false;
279
+ }
194
280
  }
195
281
  /** Pull tenant_id from JWT payload without signature verification. */
196
282
  function extractTenantId(jwtToken) {
package/dist/cli.js CHANGED
@@ -160,8 +160,10 @@ program
160
160
  .command('start')
161
161
  .description('Start the BuildHive agent (registers as a self-hosted GitHub Actions runner)')
162
162
  .option('-c, --config <path>', 'Configuration file path')
163
- .option('--owner <owner>', 'GitHub org or user that owns the repo (overrides config / env)')
164
- .option('--repo <repo>', 'GitHub repo name to register the runner for (overrides config / env)')
163
+ // Group B / RI-08: --owner + --repo retained as power-user overrides only;
164
+ // the canonical zero-GH path resolves them server-side via /api/runners/my-repos.
165
+ .option('--owner <owner>', 'Power-user override: GitHub org/user (default: resolved from BuildHive enrollment)')
166
+ .option('--repo <repo>', 'Power-user override: GitHub repo (default: resolved from BuildHive enrollment)')
165
167
  .option('-j, --jobs <n>', 'Number of concurrent ephemeral runner slots (default: 1, or maxConcurrentJobs from config; max 64)', (v) => {
166
168
  const n = parseInt(v, 10);
167
169
  if (isNaN(n) || n < 1)
@@ -1,22 +1,40 @@
1
1
  /**
2
- * doctor/index.ts — Sprint B B4 (worker-B4)
2
+ * doctor/index.ts — Sprint B B4 (worker-B4).
3
3
  *
4
- * Sequential orchestrator + ANSI formatter for the 12 diagnostic checks.
4
+ * Sequential orchestrator + ANSI formatter for the diagnostic checks.
5
5
  * Sequential by design — output ordering matters more than wall-clock speed.
6
6
  * No `chalk` dep; ANSI escape codes inline (NO_COLOR / non-TTY disables).
7
+ *
8
+ * Group B / RI-04 (expanded) — 2026-05-28: dropped the 5 stale pre-pivot
9
+ * checks (#5 config file, #6 config JSON valid, #7 apiKey format,
10
+ * #8 server URL reachable from config, #9 server-health-from-config,
11
+ * #10 api key valid via heartbeat) and replaced them with 4 runner-era
12
+ * checks that match the post-pivot model (join → keyring → my-repos).
13
+ * All "Fix" strings reference `buildhive-agent join` / `/admin/github`;
14
+ * never `init`, `register`, or `--api-key`.
7
15
  */
8
16
  import { CheckResult, CheckDeps } from './runChecks.js';
9
17
  import { CacheCheckDeps } from './cacheCheck.js';
18
+ import { RunnerCheckDeps } from './runnerChecks.js';
10
19
  /**
11
- * Run all 17 checks. Returns results (in fixed order) + exit code:
20
+ * Run all 15 checks. Returns results (in fixed order) + exit code:
12
21
  * 0 if every check passed (warnings are non-fatal advisories), 1 if any failed.
13
22
  *
14
- * @param cacheDeps - Optional cache-check deps. When omitted, check 17 uses a
15
- * default disabled-state dep so the check is a no-op advisory rather than
16
- * requiring a fully initialised CacheManager at doctor startup time. Callers
17
- * that have a loaded AgentConfig should pass `defaultCacheCheckDeps(config)`.
23
+ * Check ordering (Group B / RI-04):
24
+ * 1-4 Node + Docker (host prerequisites)
25
+ * 5-8 Runner-era identity: keyring JWT, identity claims, platformUrl
26
+ * reachable, /api/runners/my-repos returns ≥1 repo
27
+ * 9-10 Disk + workspace
28
+ * 11-14 launchd service state (post-join)
29
+ * 15 Optional cache opt-in
30
+ *
31
+ * @param cacheDeps - Optional cache-check deps. When omitted, the cache check
32
+ * uses a default disabled-state dep so it's a no-op advisory rather than
33
+ * requiring a fully initialised CacheManager at doctor startup time.
34
+ * @param runnerDeps - Optional runner-era check deps. Tests inject fakes to
35
+ * avoid touching the real OS keyring + outbound HTTP.
18
36
  */
19
- export declare function runDoctor(deps?: CheckDeps, out?: (line: string) => void, cacheDeps?: CacheCheckDeps): Promise<{
37
+ export declare function runDoctor(deps?: CheckDeps, out?: (line: string) => void, cacheDeps?: CacheCheckDeps, runnerDeps?: RunnerCheckDeps): Promise<{
20
38
  results: CheckResult[];
21
39
  exitCode: number;
22
40
  }>;
@@ -1,13 +1,22 @@
1
1
  /**
2
- * doctor/index.ts — Sprint B B4 (worker-B4)
2
+ * doctor/index.ts — Sprint B B4 (worker-B4).
3
3
  *
4
- * Sequential orchestrator + ANSI formatter for the 12 diagnostic checks.
4
+ * Sequential orchestrator + ANSI formatter for the diagnostic checks.
5
5
  * Sequential by design — output ordering matters more than wall-clock speed.
6
6
  * No `chalk` dep; ANSI escape codes inline (NO_COLOR / non-TTY disables).
7
+ *
8
+ * Group B / RI-04 (expanded) — 2026-05-28: dropped the 5 stale pre-pivot
9
+ * checks (#5 config file, #6 config JSON valid, #7 apiKey format,
10
+ * #8 server URL reachable from config, #9 server-health-from-config,
11
+ * #10 api key valid via heartbeat) and replaced them with 4 runner-era
12
+ * checks that match the post-pivot model (join → keyring → my-repos).
13
+ * All "Fix" strings reference `buildhive-agent join` / `/admin/github`;
14
+ * never `init`, `register`, or `--api-key`.
7
15
  */
8
- import { checkNodeVersion, checkDockerInstalled, checkDockerDaemon, checkDockerSocketAccess, checkConfigFile, checkConfigValid, checkApiKeyFormat, checkServerReachable, checkServerHealthShape, checkApiKeyValid, checkDiskSpace, checkWorkspaceWritable, checkServicePlistInstalled, checkServicePlistValid, checkServiceLoaded, checkServiceRunning, defaultDeps, } from './runChecks.js';
16
+ import { checkNodeVersion, checkDockerInstalled, checkDockerDaemon, checkDockerSocketAccess, checkDiskSpace, checkWorkspaceWritable, checkServicePlistInstalled, checkServicePlistValid, checkServiceLoaded, checkServiceRunning, defaultDeps, } from './runChecks.js';
9
17
  import { checkCacheEnabled } from './cacheCheck.js';
10
- const TOTAL = 17;
18
+ import { checkKeyringPresent, checkKeyringClaims, checkPlatformUrlReachable, checkEnrolledRepos, defaultRunnerCheckDeps, } from './runnerChecks.js';
19
+ const TOTAL = 15;
11
20
  const useColor = () => !process.env.NO_COLOR && Boolean(process.stdout.isTTY);
12
21
  const wrap = (code, s) => useColor() ? `\x1b[${code}m${s}\x1b[0m` : s;
13
22
  const green = (s) => wrap('32', s);
@@ -38,7 +47,7 @@ const safeAsync = async (fn) => {
38
47
  };
39
48
  /** Like `safe` but for the launchd-loaded check, which returns
40
49
  * `{result, printOutput}`. On unexpected throw we synthesize a fail row +
41
- * `loaded:false` so check 16 chains correctly. */
50
+ * `loaded:false` so the running-state check chains correctly. */
42
51
  const safeLoaded = (fn) => {
43
52
  try {
44
53
  return fn();
@@ -51,48 +60,62 @@ const safeLoaded = (fn) => {
51
60
  }
52
61
  };
53
62
  /**
54
- * Run all 17 checks. Returns results (in fixed order) + exit code:
63
+ * Run all 15 checks. Returns results (in fixed order) + exit code:
55
64
  * 0 if every check passed (warnings are non-fatal advisories), 1 if any failed.
56
65
  *
57
- * @param cacheDeps - Optional cache-check deps. When omitted, check 17 uses a
58
- * default disabled-state dep so the check is a no-op advisory rather than
59
- * requiring a fully initialised CacheManager at doctor startup time. Callers
60
- * that have a loaded AgentConfig should pass `defaultCacheCheckDeps(config)`.
66
+ * Check ordering (Group B / RI-04):
67
+ * 1-4 Node + Docker (host prerequisites)
68
+ * 5-8 Runner-era identity: keyring JWT, identity claims, platformUrl
69
+ * reachable, /api/runners/my-repos returns ≥1 repo
70
+ * 9-10 Disk + workspace
71
+ * 11-14 launchd service state (post-join)
72
+ * 15 Optional cache opt-in
73
+ *
74
+ * @param cacheDeps - Optional cache-check deps. When omitted, the cache check
75
+ * uses a default disabled-state dep so it's a no-op advisory rather than
76
+ * requiring a fully initialised CacheManager at doctor startup time.
77
+ * @param runnerDeps - Optional runner-era check deps. Tests inject fakes to
78
+ * avoid touching the real OS keyring + outbound HTTP.
61
79
  */
62
- export async function runDoctor(deps = defaultDeps(), out = (line) => process.stdout.write(line + '\n'), cacheDeps) {
80
+ export async function runDoctor(deps = defaultDeps(), out = (line) => process.stdout.write(line + '\n'), cacheDeps, runnerDeps) {
63
81
  out(bold('Running BuildHive agent diagnostics...\n'));
64
82
  const results = [];
83
+ // 1-4 host prerequisites
65
84
  results.push(safe(() => checkNodeVersion(deps)));
66
85
  results.push(safe(() => checkDockerInstalled(deps)));
67
86
  results.push(safe(() => checkDockerDaemon(deps)));
68
87
  results.push(safe(() => checkDockerSocketAccess(deps)));
69
- results.push(await safeAsync(() => checkConfigFile(deps)));
70
- // Check 6 returns parsed payload — thread it into deps.config so checks
71
- // 7-10 see it without re-reading the file.
72
- let v;
88
+ // 5-8 runner-era identity (Group B / RI-04)
89
+ // Check 5 returns the keyring state — thread it into checks 6-8 so we
90
+ // don't re-read the keyring per check (matches the orchestrator's
91
+ // 6→7-10 config-threading pattern from before the rewrite).
92
+ const effectiveRunnerDeps = runnerDeps ?? defaultRunnerCheckDeps();
93
+ let keyring;
73
94
  try {
74
- v = await checkConfigValid(deps);
95
+ keyring = await checkKeyringPresent(effectiveRunnerDeps);
75
96
  }
76
97
  catch (err) {
77
- v = { result: errResult('Config valid JSON', err) };
98
+ keyring = {
99
+ result: errResult('Agent enrollment JWT (keyring)', err),
100
+ state: { jwt: null, platformUrl: null, agentId: null, tenantId: null },
101
+ };
78
102
  }
79
- results.push(v.result);
80
- if (v.parsed)
81
- deps.config = v.parsed;
82
- results.push(safe(() => checkApiKeyFormat(deps.config)));
83
- results.push(await safeAsync(() => checkServerReachable(deps)));
84
- results.push(await safeAsync(() => checkServerHealthShape(deps)));
85
- results.push(await safeAsync(() => checkApiKeyValid(deps)));
103
+ results.push(keyring.result);
104
+ results.push(safe(() => checkKeyringClaims(keyring.state)));
105
+ results.push(await safeAsync(() => checkPlatformUrlReachable(keyring.state, effectiveRunnerDeps)));
106
+ results.push(await safeAsync(() => checkEnrolledRepos(keyring.state, effectiveRunnerDeps)));
107
+ // 9-10 disk + workspace
86
108
  results.push(await safeAsync(() => checkDiskSpace(deps)));
87
109
  results.push(await safeAsync(() => checkWorkspaceWritable(deps)));
88
- // 13-16: launchd service state. Check 15 emits the `launchctl print` stdout
89
- // which check 16 parses same threading pattern as 6→7-10 for config.
110
+ // 11-14 launchd service state (post-join `join` auto-installs the
111
+ // service per S-1, so a green run-state row is the expected steady
112
+ // state on macOS).
90
113
  results.push(await safeAsync(() => checkServicePlistInstalled(deps)));
91
114
  results.push(await safeAsync(() => checkServicePlistValid(deps)));
92
115
  const loaded = safeLoaded(() => checkServiceLoaded(deps));
93
116
  results.push(loaded.result);
94
117
  results.push(safe(() => checkServiceRunning(deps, loaded.printOutput)));
95
- // 17: on-device build cache opt-in state (Q7 touchpoint #2 — doctor banner)
118
+ // 15 on-device build cache opt-in state (Q7 touchpoint #2 — doctor banner)
96
119
  const effectiveCacheDeps = cacheDeps ?? {
97
120
  enabled: false,
98
121
  getStats: async () => ({ totalEntries: 0, totalSizeBytes: 0, maxSizeBytes: 0, hitRate: 0, totalHits: 0, totalMisses: 0 }),
@@ -100,7 +123,7 @@ export async function runDoctor(deps = defaultDeps(), out = (line) => process.st
100
123
  };
101
124
  results.push(await safeAsync(() => checkCacheEnabled(effectiveCacheDeps)));
102
125
  results.forEach((r, i) => {
103
- out(`[${i + 1}/${TOTAL}] ${r.name.padEnd(30, ' ')} ${symbol(r.status)} ${r.message}`);
126
+ out(`[${i + 1}/${TOTAL}] ${r.name.padEnd(38, ' ')} ${symbol(r.status)} ${r.message}`);
104
127
  if (r.fix && r.status !== 'pass')
105
128
  out(` ${dim('Fix: ' + r.fix)}`);
106
129
  });
@@ -35,29 +35,13 @@ export interface CheckDeps {
35
35
  platform: NodeJS.Platform;
36
36
  groupsCmd: () => string;
37
37
  homedir: string;
38
- /** Parsed payload from check 6 — threaded into checks 7-10 by the orchestrator. */
39
- config?: {
40
- apiKey?: unknown;
41
- serverUrl?: unknown;
42
- platformUrl?: unknown;
43
- };
44
38
  }
45
- export declare const DEFAULT_CONFIG_PATH: (homedir: string) => string;
46
39
  export declare const DEFAULT_WORKSPACE_PATH: (homedir: string) => string;
47
40
  export declare function defaultDeps(): CheckDeps;
48
41
  export declare function checkNodeVersion(deps: Pick<CheckDeps, 'nodeVersion'>): CheckResult;
49
42
  export declare function checkDockerInstalled(deps: Pick<CheckDeps, 'exec'>): CheckResult;
50
43
  export declare function checkDockerDaemon(deps: Pick<CheckDeps, 'exec'>): CheckResult;
51
44
  export declare function checkDockerSocketAccess(deps: Pick<CheckDeps, 'platform' | 'groupsCmd'>): CheckResult;
52
- export declare function checkConfigFile(deps: Pick<CheckDeps, 'fileExists' | 'homedir'>): Promise<CheckResult>;
53
- export declare function checkConfigValid(deps: Pick<CheckDeps, 'readFile' | 'fileExists' | 'homedir'>): Promise<{
54
- result: CheckResult;
55
- parsed?: Record<string, unknown>;
56
- }>;
57
- export declare function checkApiKeyFormat(config: CheckDeps['config']): CheckResult;
58
- export declare function checkServerReachable(deps: Pick<CheckDeps, 'fetcher' | 'config'>): Promise<CheckResult>;
59
- export declare function checkServerHealthShape(deps: Pick<CheckDeps, 'fetcher' | 'config'>): Promise<CheckResult>;
60
- export declare function checkApiKeyValid(deps: Pick<CheckDeps, 'fetcher' | 'config'>): Promise<CheckResult>;
61
45
  export declare function checkDiskSpace(deps: Pick<CheckDeps, 'diskFreeBytes' | 'homedir'>): Promise<CheckResult>;
62
46
  export declare function checkWorkspaceWritable(deps: Pick<CheckDeps, 'mkdir' | 'writeFile' | 'unlink' | 'homedir'>): Promise<CheckResult>;
63
47
  export declare function checkServicePlistInstalled(deps: Pick<CheckDeps, 'fileExists' | 'platform' | 'homedir'>): Promise<CheckResult>;
@@ -14,7 +14,9 @@ import { execSync } from 'child_process';
14
14
  import { join } from 'path';
15
15
  import os from 'os';
16
16
  import { AGENT_LABEL, getServicePaths } from '../service/paths.js';
17
- export const DEFAULT_CONFIG_PATH = (homedir) => join(homedir, '.buildhive', 'buildhive-agent.json');
17
+ // Group B / RI-04: DEFAULT_CONFIG_PATH was the home of the legacy
18
+ // `~/.buildhive/buildhive-agent.json` file. The runner-era stores
19
+ // credentials in the OS keyring instead — keep only WORKSPACE_PATH.
18
20
  export const DEFAULT_WORKSPACE_PATH = (homedir) => join(homedir, '.buildhive', 'workspaces');
19
21
  export function defaultDeps() {
20
22
  return {
@@ -109,167 +111,14 @@ export function checkDockerSocketAccess(deps) {
109
111
  };
110
112
  }
111
113
  }
112
- // 5: config file exists
113
- export async function checkConfigFile(deps) {
114
- const path = DEFAULT_CONFIG_PATH(deps.homedir);
115
- if (await deps.fileExists(path)) {
116
- return { name: 'Config file', status: 'pass', message: `Found at ${path}` };
117
- }
118
- return {
119
- name: 'Config file', status: 'fail',
120
- message: `Not found at ${path}`,
121
- fix: 'Run `buildhive-agent init` first',
122
- };
123
- }
124
- // 6: parseable JSON. Returns parsed payload — orchestrator threads it into
125
- // CheckDeps.config so checks 7-10 don't re-read the file.
126
- export async function checkConfigValid(deps) {
127
- const path = DEFAULT_CONFIG_PATH(deps.homedir);
128
- if (!(await deps.fileExists(path))) {
129
- return { result: {
130
- name: 'Config valid JSON', status: 'fail',
131
- message: 'Config file missing (see previous check)',
132
- fix: 'Run `buildhive-agent init` first',
133
- } };
134
- }
135
- try {
136
- const parsed = JSON.parse(await deps.readFile(path));
137
- return { result: { name: 'Config valid JSON', status: 'pass', message: 'Parses cleanly' }, parsed };
138
- }
139
- catch (err) {
140
- return { result: {
141
- name: 'Config valid JSON', status: 'fail',
142
- message: `Invalid JSON: ${errMsg(err).slice(0, 80)}`,
143
- fix: 'Re-run `buildhive-agent init --force`',
144
- } };
145
- }
146
- }
147
- // 7: API key shape — bh_api_<hex>. Loose so server-side prefix tweaks don't
148
- // make the doctor cry wolf.
149
- export function checkApiKeyFormat(config) {
150
- const key = typeof config?.apiKey === 'string' ? config.apiKey : '';
151
- const keyFix = 'Recreate API key in dashboard /admin/keys, then `buildhive-agent init --force --api-key <new>`';
152
- if (!key) {
153
- return { name: 'API key format', status: 'fail', message: 'apiKey field missing or empty', fix: keyFix };
154
- }
155
- if (!/^bh_api_[a-f0-9]{16,}$/i.test(key)) {
156
- return {
157
- name: 'API key format', status: 'fail',
158
- message: `apiKey doesn't match bh_api_<hex> (got ${key.slice(0, 12)}…)`,
159
- fix: keyFix,
160
- };
161
- }
162
- return { name: 'API key format', status: 'pass', message: `bh_api_…${key.slice(-4)}` };
163
- }
164
- function getServerUrl(config) {
165
- for (const c of [config?.platformUrl, config?.serverUrl]) {
166
- if (typeof c === 'string' && c.trim())
167
- return c.trim().replace(/\/+$/, '');
168
- }
169
- return null;
170
- }
171
- const URL_FIX = 'Check the URL is correct (default: https://api.buildhive.app) and your network allows outbound HTTPS';
172
- const HEALTH_FIX = 'Server may be deploying or down — check status.buildhive.app or wait 60s';
173
- // 8: server reachable — GET /health within 5s
174
- export async function checkServerReachable(deps) {
175
- const base = getServerUrl(deps.config);
176
- if (!base) {
177
- return {
178
- name: 'Server URL reachable', status: 'fail',
179
- message: 'No platformUrl in config', fix: 'Re-run `buildhive-agent init --force`',
180
- };
181
- }
182
- try {
183
- // AbortSignal.timeout is the cleanest fetch timeout. We require Node ≥ 18
184
- // (check 1) so it's always present.
185
- const r = await deps.fetcher(`${base}/health`, { signal: AbortSignal.timeout(5000) });
186
- if (r.ok)
187
- return { name: 'Server URL reachable', status: 'pass', message: `${base} → ${r.status}` };
188
- return {
189
- name: 'Server URL reachable', status: 'fail',
190
- message: `${base}/health returned HTTP ${r.status}`, fix: URL_FIX,
191
- };
192
- }
193
- catch (err) {
194
- return {
195
- name: 'Server URL reachable', status: 'fail',
196
- message: `Cannot reach ${base}: ${errMsg(err).slice(0, 80)}`, fix: URL_FIX,
197
- };
198
- }
199
- }
200
- // 9: /health body shape — {"status":"healthy"}
201
- export async function checkServerHealthShape(deps) {
202
- const base = getServerUrl(deps.config);
203
- if (!base)
204
- return { name: 'Server health response', status: 'fail', message: 'No platformUrl in config' };
205
- try {
206
- const body = await (await deps.fetcher(`${base}/health`, { signal: AbortSignal.timeout(5000) })).text();
207
- let parsed;
208
- try {
209
- parsed = JSON.parse(body);
210
- }
211
- catch {
212
- return {
213
- name: 'Server health response', status: 'warn',
214
- message: `Health endpoint returned non-JSON (${body.slice(0, 40)}…)`,
215
- };
216
- }
217
- if (parsed.status === 'healthy') {
218
- return { name: 'Server health response', status: 'pass', message: '{"status":"healthy"}' };
219
- }
220
- return {
221
- name: 'Server health response', status: 'warn',
222
- message: `Server reports status=${String(parsed.status ?? 'unknown')}`, fix: HEALTH_FIX,
223
- };
224
- }
225
- catch (err) {
226
- return {
227
- name: 'Server health response', status: 'fail',
228
- message: `Health check failed: ${errMsg(err).slice(0, 80)}`, fix: HEALTH_FIX,
229
- };
230
- }
231
- }
232
- // 10: API key valid — heartbeat returns anything except 401/403. We pass an
233
- // obviously-fake agentId; server should reject with 4xx based on payload, NOT
234
- // auth, when the key itself is good.
235
- export async function checkApiKeyValid(deps) {
236
- const base = getServerUrl(deps.config);
237
- const key = typeof deps.config?.apiKey === 'string' ? deps.config.apiKey : '';
238
- if (!base || !key) {
239
- return {
240
- name: 'API key valid against server', status: 'fail',
241
- message: 'Missing platformUrl or apiKey in config',
242
- };
243
- }
244
- try {
245
- const r = await deps.fetcher(`${base}/api/agents/heartbeat`, {
246
- method: 'POST',
247
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
248
- body: JSON.stringify({
249
- agentId: '00000000-0000-0000-0000-000000000000',
250
- status: 'ONLINE', currentLoad: 0, activeJobs: 0,
251
- }),
252
- signal: AbortSignal.timeout(5000),
253
- });
254
- if (r.status === 401 || r.status === 403) {
255
- return {
256
- name: 'API key valid against server', status: 'fail',
257
- message: `Server rejected key with HTTP ${r.status}`,
258
- fix: 'API key revoked or wrong tenant — recreate in /admin/keys',
259
- };
260
- }
261
- return {
262
- name: 'API key valid against server', status: 'pass',
263
- message: `Server accepted key (HTTP ${r.status})`,
264
- };
265
- }
266
- catch (err) {
267
- return {
268
- name: 'API key valid against server', status: 'warn',
269
- message: `Could not verify key (network error: ${errMsg(err).slice(0, 60)})`,
270
- };
271
- }
272
- }
114
+ // Checks 5-10 from the pre-pivot era (config file → JSON parse →
115
+ // apiKey format → server reachable → /health body shape → apiKey valid)
116
+ // have been DELETED in Group B / RI-04 (2026-05-28). They tested for
117
+ // `~/.buildhive/buildhive-agent.json` and the `bh_api_*` key model that
118
+ // the runner-era no longer uses. The replacement checks live in
119
+ // `./runnerChecks.ts` (keyring JWT, identity, platformUrl reachable,
120
+ // my-repos returns ≥1 repo) and use `buildhive-agent join` /
121
+ // `/admin/github` in their Fix strings.
273
122
  const ONE_GB = 1024 * 1024 * 1024;
274
123
  // 11: ≥ 1 GB free at workspaces root (or closest existing ancestor — statfs
275
124
  // needs an extant path).
@@ -339,7 +188,12 @@ export async function checkWorkspaceWritable(deps) {
339
188
  // resilience-diagnostic AGT-G1 follow-up. macOS-only for the user-mode
340
189
  // LaunchAgent path; Linux + Windows return a non-applicable pass row so the
341
190
  // orchestrator never short-circuits on platform.
342
- const SERVICE_INSTALL_FIX = 'Run `buildhive-agent service:install` (or `service:migrate` if upgrading from a legacy install)';
191
+ // Group B / RI-04 `join` now auto-installs the LaunchAgent (S-1), so the
192
+ // canonical remediation for a missing/unloaded service is to re-run `join`.
193
+ // `service:install` remains as the manual fallback for advanced users (e.g.
194
+ // when the keyring JWT is intact but the plist was hand-deleted).
195
+ const SERVICE_INSTALL_FIX = 'Re-run `buildhive-agent join <token>` (auto-installs the service). ' +
196
+ 'Or, if the keyring is fine, run `buildhive-agent service:install` to reinstall the plist only.';
343
197
  // 13: plist file present at the expected user-scope path
344
198
  export async function checkServicePlistInstalled(deps) {
345
199
  if (deps.platform !== 'darwin') {
@@ -0,0 +1,54 @@
1
+ /**
2
+ * doctor/runnerChecks.ts — Group B / RI-04 (expanded).
3
+ *
4
+ * Replaces the stale pre-pivot diagnostic checks (#5 config file,
5
+ * #7 apiKey format, #8 platformUrl in config, #9-#10 cascades) with
6
+ * checks that match the runner-era model:
7
+ *
8
+ * - Keyring entry `agent-enrollment.jwt` present
9
+ * - agent_id + tenant_id resolvable from the keyring payload
10
+ * - platformUrl reachable (HTTP GET /health)
11
+ * - GET /api/runners/my-repos returns ≥1 repo for the agent's tenant
12
+ *
13
+ * Fix strings ALWAYS reference `buildhive-agent join` and `/admin/github`;
14
+ * never `init`, `register`, or `--api-key`.
15
+ *
16
+ * Design refs: docs/ops/buildhive-reinit-fix-plan-2026-05-28.html §Group B (RI-04).
17
+ */
18
+ import type { CheckResult } from './runChecks.js';
19
+ /**
20
+ * Keyring-credential payload threaded from check 1 → checks 2-4.
21
+ * Mirrors the orchestrator's `config` plumbing pattern.
22
+ */
23
+ export interface RunnerCheckState {
24
+ readonly jwt: string | null;
25
+ readonly platformUrl: string | null;
26
+ readonly agentId: string | null;
27
+ readonly tenantId: string | null;
28
+ }
29
+ /**
30
+ * DI surface — production callers pass `defaultRunnerCheckDeps()`; tests
31
+ * supply fakes so no real keyring / network access is required.
32
+ */
33
+ export interface RunnerCheckDeps {
34
+ /** Build / read the agent-enrollment keyring store. */
35
+ readonly readKeyring: () => Promise<RunnerCheckState>;
36
+ /** HTTP client (defaults to global fetch). */
37
+ readonly fetcher: (url: string, init?: RequestInit) => Promise<{
38
+ ok: boolean;
39
+ status: number;
40
+ json: () => Promise<unknown>;
41
+ text: () => Promise<string>;
42
+ }>;
43
+ }
44
+ export declare function defaultRunnerCheckDeps(): RunnerCheckDeps;
45
+ /**
46
+ * Returns the read state so subsequent checks can reuse it (no re-read).
47
+ */
48
+ export declare function checkKeyringPresent(deps: Pick<RunnerCheckDeps, 'readKeyring'>): Promise<{
49
+ result: CheckResult;
50
+ state: RunnerCheckState;
51
+ }>;
52
+ export declare function checkKeyringClaims(state: RunnerCheckState): CheckResult;
53
+ export declare function checkPlatformUrlReachable(state: RunnerCheckState, deps: Pick<RunnerCheckDeps, 'fetcher'>): Promise<CheckResult>;
54
+ export declare function checkEnrolledRepos(state: RunnerCheckState, deps: Pick<RunnerCheckDeps, 'fetcher'>): Promise<CheckResult>;
@@ -0,0 +1,212 @@
1
+ /**
2
+ * doctor/runnerChecks.ts — Group B / RI-04 (expanded).
3
+ *
4
+ * Replaces the stale pre-pivot diagnostic checks (#5 config file,
5
+ * #7 apiKey format, #8 platformUrl in config, #9-#10 cascades) with
6
+ * checks that match the runner-era model:
7
+ *
8
+ * - Keyring entry `agent-enrollment.jwt` present
9
+ * - agent_id + tenant_id resolvable from the keyring payload
10
+ * - platformUrl reachable (HTTP GET /health)
11
+ * - GET /api/runners/my-repos returns ≥1 repo for the agent's tenant
12
+ *
13
+ * Fix strings ALWAYS reference `buildhive-agent join` and `/admin/github`;
14
+ * never `init`, `register`, or `--api-key`.
15
+ *
16
+ * Design refs: docs/ops/buildhive-reinit-fix-plan-2026-05-28.html §Group B (RI-04).
17
+ */
18
+ import { AgentEnrollmentKeyringStore } from '../auth/agentEnrollmentKeyringStore.js';
19
+ const JOIN_FIX = 'Run `buildhive-agent join <token>`. Get the token from /admin/github in your BuildHive dashboard.';
20
+ const ENROLL_FIX = 'Open /admin/github in your BuildHive dashboard, enroll at least one repo, then re-run `buildhive-agent join <token>`.';
21
+ const NETWORK_FIX = 'Check the platform URL is correct (default: https://api.buildhive.app) and your network allows outbound HTTPS.';
22
+ const HEALTH_FIX = 'BuildHive server may be deploying or down — wait 60s and retry, or check status.buildhive.app.';
23
+ export function defaultRunnerCheckDeps() {
24
+ return {
25
+ readKeyring: async () => {
26
+ try {
27
+ const store = new AgentEnrollmentKeyringStore();
28
+ const creds = await store.readAll();
29
+ return {
30
+ jwt: creds.jwt ?? null,
31
+ platformUrl: creds.platformUrl ?? null,
32
+ agentId: creds.agentId ?? null,
33
+ tenantId: creds.tenantId ?? null,
34
+ };
35
+ }
36
+ catch {
37
+ return { jwt: null, platformUrl: null, agentId: null, tenantId: null };
38
+ }
39
+ },
40
+ fetcher: (url, init) => globalThis.fetch(url, init).then((r) => r),
41
+ };
42
+ }
43
+ // ─── Check 1: keyring entry present ───────────────────────────────────────
44
+ /**
45
+ * Returns the read state so subsequent checks can reuse it (no re-read).
46
+ */
47
+ export async function checkKeyringPresent(deps) {
48
+ let state;
49
+ try {
50
+ state = await deps.readKeyring();
51
+ }
52
+ catch {
53
+ state = { jwt: null, platformUrl: null, agentId: null, tenantId: null };
54
+ }
55
+ if (state.jwt && state.jwt.length > 0) {
56
+ return {
57
+ result: {
58
+ name: 'Agent enrollment JWT (keyring)',
59
+ status: 'pass',
60
+ message: 'Found in OS keyring under agent-enrollment.jwt',
61
+ },
62
+ state,
63
+ };
64
+ }
65
+ return {
66
+ result: {
67
+ name: 'Agent enrollment JWT (keyring)',
68
+ status: 'fail',
69
+ message: 'No agent-enrollment JWT in the OS keyring',
70
+ fix: JOIN_FIX,
71
+ },
72
+ state,
73
+ };
74
+ }
75
+ // ─── Check 2: agent_id + tenant_id resolvable ─────────────────────────────
76
+ export function checkKeyringClaims(state) {
77
+ if (!state.jwt) {
78
+ return {
79
+ name: 'Agent identity (agent_id + tenant_id)',
80
+ status: 'fail',
81
+ message: 'Cannot resolve identity — no JWT in keyring',
82
+ fix: JOIN_FIX,
83
+ };
84
+ }
85
+ if (!state.agentId || !state.tenantId) {
86
+ return {
87
+ name: 'Agent identity (agent_id + tenant_id)',
88
+ status: 'fail',
89
+ message: 'JWT present but agent_id or tenant_id missing from keyring payload',
90
+ fix: 'Keyring payload is incomplete — re-enroll with `buildhive-agent join <token>`.',
91
+ };
92
+ }
93
+ return {
94
+ name: 'Agent identity (agent_id + tenant_id)',
95
+ status: 'pass',
96
+ message: `agent_id=${state.agentId.slice(0, 8)}… tenant_id=${state.tenantId.slice(0, 8)}…`,
97
+ };
98
+ }
99
+ // ─── Check 3: platformUrl reachable ────────────────────────────────────────
100
+ export async function checkPlatformUrlReachable(state, deps) {
101
+ const base = state.platformUrl?.trim().replace(/\/+$/, '');
102
+ if (!base) {
103
+ return {
104
+ name: 'Platform URL reachable',
105
+ status: 'fail',
106
+ message: 'No platformUrl in the keyring (agent not enrolled)',
107
+ fix: JOIN_FIX,
108
+ };
109
+ }
110
+ try {
111
+ const r = await deps.fetcher(`${base}/health`, { signal: AbortSignal.timeout(5000) });
112
+ if (r.ok) {
113
+ return {
114
+ name: 'Platform URL reachable',
115
+ status: 'pass',
116
+ message: `${base} → ${r.status}`,
117
+ };
118
+ }
119
+ return {
120
+ name: 'Platform URL reachable',
121
+ status: 'fail',
122
+ message: `${base}/health returned HTTP ${r.status}`,
123
+ fix: HEALTH_FIX,
124
+ };
125
+ }
126
+ catch (err) {
127
+ const msg = err instanceof Error ? err.message : String(err);
128
+ return {
129
+ name: 'Platform URL reachable',
130
+ status: 'fail',
131
+ message: `Cannot reach ${base}: ${msg.slice(0, 80)}`,
132
+ fix: NETWORK_FIX,
133
+ };
134
+ }
135
+ }
136
+ // ─── Check 4: /api/runners/my-repos returns ≥1 repo ────────────────────────
137
+ export async function checkEnrolledRepos(state, deps) {
138
+ const base = state.platformUrl?.trim().replace(/\/+$/, '');
139
+ if (!base || !state.jwt) {
140
+ return {
141
+ name: 'Tenant has ≥1 enrolled repo',
142
+ status: 'fail',
143
+ message: 'Cannot query — no JWT or platformUrl available',
144
+ fix: JOIN_FIX,
145
+ };
146
+ }
147
+ let response;
148
+ try {
149
+ response = await deps.fetcher(`${base}/api/runners/my-repos`, {
150
+ method: 'GET',
151
+ headers: { Accept: 'application/json', Authorization: `Bearer ${state.jwt}` },
152
+ signal: AbortSignal.timeout(5000),
153
+ });
154
+ }
155
+ catch (err) {
156
+ const msg = err instanceof Error ? err.message : String(err);
157
+ return {
158
+ name: 'Tenant has ≥1 enrolled repo',
159
+ status: 'fail',
160
+ message: `Network error querying /api/runners/my-repos: ${msg.slice(0, 80)}`,
161
+ fix: NETWORK_FIX,
162
+ };
163
+ }
164
+ if (response.status === 401) {
165
+ return {
166
+ name: 'Tenant has ≥1 enrolled repo',
167
+ status: 'fail',
168
+ message: 'Server rejected the agent JWT (401) — token revoked or expired',
169
+ fix: JOIN_FIX,
170
+ };
171
+ }
172
+ if (!response.ok) {
173
+ return {
174
+ name: 'Tenant has ≥1 enrolled repo',
175
+ status: 'fail',
176
+ message: `/api/runners/my-repos returned HTTP ${response.status}`,
177
+ fix: HEALTH_FIX,
178
+ };
179
+ }
180
+ let body;
181
+ try {
182
+ body = await response.json();
183
+ }
184
+ catch (err) {
185
+ return {
186
+ name: 'Tenant has ≥1 enrolled repo',
187
+ status: 'warn',
188
+ message: `Could not parse my-repos response body: ${err instanceof Error ? err.message : String(err)}`,
189
+ };
190
+ }
191
+ if (!body || typeof body !== 'object' || !Array.isArray(body.repos)) {
192
+ return {
193
+ name: 'Tenant has ≥1 enrolled repo',
194
+ status: 'warn',
195
+ message: 'Malformed my-repos response (missing repos array)',
196
+ };
197
+ }
198
+ const repos = body.repos;
199
+ if (repos.length === 0) {
200
+ return {
201
+ name: 'Tenant has ≥1 enrolled repo',
202
+ status: 'fail',
203
+ message: 'Your tenant has no enrolled repos',
204
+ fix: ENROLL_FIX,
205
+ };
206
+ }
207
+ return {
208
+ name: 'Tenant has ≥1 enrolled repo',
209
+ status: 'pass',
210
+ message: `${repos.length} enrolled repo(s)`,
211
+ };
212
+ }
@@ -32,11 +32,22 @@
32
32
  */
33
33
  export declare function validateRunnerVersion(version: string): void;
34
34
  /**
35
- * Wave 1 pinned runner version.
36
- * Matches the latest stable actions/runner release at the time this was written.
37
- * Operators can override via BUILDHIVE_RUNNER_VERSION env var for testing.
35
+ * Pinned actions/runner version.
36
+ *
37
+ * GitHub deprecates older runner versions roughly every 2-3 months and refuses
38
+ * connections from them ("Runner version vX.Y.Z is deprecated and cannot
39
+ * receive messages") — when that happens, no job can run on a BuildHive
40
+ * agent until this constant is bumped + a new buildhive-agent beta published.
41
+ *
42
+ * Operators can override via BUILDHIVE_RUNNER_VERSION env var for testing
43
+ * (used during the 2026-05-31 walk to confirm v2.334.0 unblocks the flow).
44
+ *
45
+ * Bump policy: keep within ~1 minor release of the latest stable tag at
46
+ * https://github.com/actions/runner/releases. The
47
+ * `.github/workflows/check-actions-runner-version.yml` cron opens a
48
+ * tracking issue weekly when this constant falls behind upstream.
38
49
  */
39
- export declare const DEFAULT_RUNNER_VERSION = "2.325.0";
50
+ export declare const DEFAULT_RUNNER_VERSION = "2.334.0";
40
51
  /**
41
52
  * Platform-not-supported error message.
42
53
  * Exact string required by task spec so tests can assert on it.
@@ -61,11 +61,22 @@ const MAX_TARBALL_BYTES = 500 * 1024 * 1024;
61
61
  const MAX_RELEASE_BODY_BYTES = 1 * 1024 * 1024;
62
62
  const logger = createLogger('runner.binaryFetcher');
63
63
  /**
64
- * Wave 1 pinned runner version.
65
- * Matches the latest stable actions/runner release at the time this was written.
66
- * Operators can override via BUILDHIVE_RUNNER_VERSION env var for testing.
64
+ * Pinned actions/runner version.
65
+ *
66
+ * GitHub deprecates older runner versions roughly every 2-3 months and refuses
67
+ * connections from them ("Runner version vX.Y.Z is deprecated and cannot
68
+ * receive messages") — when that happens, no job can run on a BuildHive
69
+ * agent until this constant is bumped + a new buildhive-agent beta published.
70
+ *
71
+ * Operators can override via BUILDHIVE_RUNNER_VERSION env var for testing
72
+ * (used during the 2026-05-31 walk to confirm v2.334.0 unblocks the flow).
73
+ *
74
+ * Bump policy: keep within ~1 minor release of the latest stable tag at
75
+ * https://github.com/actions/runner/releases. The
76
+ * `.github/workflows/check-actions-runner-version.yml` cron opens a
77
+ * tracking issue weekly when this constant falls behind upstream.
67
78
  */
68
- export const DEFAULT_RUNNER_VERSION = '2.325.0';
79
+ export const DEFAULT_RUNNER_VERSION = '2.334.0';
69
80
  /**
70
81
  * Platform-not-supported error message.
71
82
  * Exact string required by task spec so tests can assert on it.
@@ -221,6 +232,12 @@ export function parseSha256FromReleaseBody(body, platform) {
221
232
  *
222
233
  * Security considerations:
223
234
  * - User-Agent header is required for unauthenticated GitHub API requests (403 otherwise).
235
+ * - Optional Authorization header from GITHUB_TOKEN/GH_TOKEN env raises the
236
+ * per-IP anonymous limit (60/hr) to the authenticated per-repo limit
237
+ * (5000/hr). Used by CI (runner-version-validate workflow injects
238
+ * `${{ github.token }}`) so shared-IP rate-limit exhaustion can't block a
239
+ * runner-version bump PR. On user machines the env vars are absent; the
240
+ * call stays anonymous (one call per cold install).
224
241
  * - Response body is capped at MAX_RELEASE_BODY_BYTES to prevent OOM on adversarial responses.
225
242
  * - Fail-closed: throws on 4xx/5xx, missing platform marker, or malformed hash.
226
243
  * - HTTPS enforced (api.github.com URL is always HTTPS).
@@ -233,17 +250,26 @@ export function parseSha256FromReleaseBody(body, platform) {
233
250
  */
234
251
  async function fetchRunnerSha256(version, platform, fetchFn) {
235
252
  const apiUrl = `https://api.github.com/repos/actions/runner/releases/tags/v${version}`;
236
- logger.info('Fetching runner checksum from GitHub Releases API', { apiUrl, platform });
253
+ const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
254
+ const headers = {
255
+ 'Accept': 'application/vnd.github+json',
256
+ 'User-Agent': `BuildHive-Agent/${AGENT_VERSION}`,
257
+ };
258
+ if (token) {
259
+ headers['Authorization'] = `Bearer ${token}`;
260
+ }
261
+ logger.info('Fetching runner checksum from GitHub Releases API', {
262
+ apiUrl,
263
+ platform,
264
+ authenticated: Boolean(token),
265
+ });
237
266
  const controller = new AbortController();
238
267
  const timer = setTimeout(() => controller.abort(), 30_000);
239
268
  let body;
240
269
  try {
241
270
  const resp = await fetchFn(apiUrl, {
242
271
  signal: controller.signal,
243
- headers: {
244
- 'Accept': 'application/vnd.github+json',
245
- 'User-Agent': `BuildHive-Agent/${AGENT_VERSION}`,
246
- },
272
+ headers,
247
273
  });
248
274
  if (resp.status === 403) {
249
275
  throw new Error(`GitHub API responded with 403 when fetching release v${version} — ` +
@@ -0,0 +1,29 @@
1
+ /**
2
+ * myReposClient — fetches the agent's enrolled repos from the BuildHive
3
+ * backend's GET /api/runners/my-repos endpoint.
4
+ *
5
+ * Group B / RI-08. Replaces the BUILDHIVE_OWNER / BUILDHIVE_REPO env-var
6
+ * read path in startCommand.ts with a server-side resolution call — the
7
+ * developer no longer needs to know which GitHub org/repo to point at.
8
+ *
9
+ * Wire contract: server returns
10
+ * { repos: [{ owner: string, repo: string, installationId: string }] }
11
+ */
12
+ export interface EnrolledRepoInfo {
13
+ readonly owner: string;
14
+ readonly repo: string;
15
+ readonly installationId: string;
16
+ }
17
+ export interface FetchMyReposOptions {
18
+ /** BuildHive backend URL (e.g. https://api.buildhive.app) */
19
+ readonly platformUrl: string;
20
+ /** Agent enrollment JWT to use as Bearer token */
21
+ readonly jwt: string;
22
+ /** Override fetch for tests */
23
+ readonly fetchFn?: typeof fetch;
24
+ }
25
+ /**
26
+ * GET /api/runners/my-repos and return the parsed list.
27
+ * Throws a descriptive Error on any non-200 response or network failure.
28
+ */
29
+ export declare function fetchMyRepos(opts: FetchMyReposOptions): Promise<ReadonlyArray<EnrolledRepoInfo>>;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * myReposClient — fetches the agent's enrolled repos from the BuildHive
3
+ * backend's GET /api/runners/my-repos endpoint.
4
+ *
5
+ * Group B / RI-08. Replaces the BUILDHIVE_OWNER / BUILDHIVE_REPO env-var
6
+ * read path in startCommand.ts with a server-side resolution call — the
7
+ * developer no longer needs to know which GitHub org/repo to point at.
8
+ *
9
+ * Wire contract: server returns
10
+ * { repos: [{ owner: string, repo: string, installationId: string }] }
11
+ */
12
+ import { createLogger } from '../utils/logger.js';
13
+ import { redactSecrets } from '../security/logRedactor.js';
14
+ const logger = createLogger('runner.myReposClient');
15
+ const FETCH_TIMEOUT_MS = 30_000;
16
+ /**
17
+ * GET /api/runners/my-repos and return the parsed list.
18
+ * Throws a descriptive Error on any non-200 response or network failure.
19
+ */
20
+ export async function fetchMyRepos(opts) {
21
+ const { platformUrl, jwt } = opts;
22
+ const fetchFn = opts.fetchFn ?? fetch;
23
+ const url = `${platformUrl.replace(/\/$/, '')}/api/runners/my-repos`;
24
+ logger.info('Fetching enrolled repos', { url });
25
+ const controller = new AbortController();
26
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
27
+ let response;
28
+ try {
29
+ response = await fetchFn(url, {
30
+ method: 'GET',
31
+ headers: {
32
+ Accept: 'application/json',
33
+ Authorization: `Bearer ${jwt}`,
34
+ },
35
+ signal: controller.signal,
36
+ });
37
+ }
38
+ catch (err) {
39
+ const msg = err instanceof Error && err.name === 'AbortError'
40
+ ? `Timed out after ${FETCH_TIMEOUT_MS}ms fetching enrolled repos`
41
+ : `Network error fetching enrolled repos: ${err instanceof Error ? err.message : String(err)}`;
42
+ logger.error(msg);
43
+ throw new Error(msg);
44
+ }
45
+ finally {
46
+ clearTimeout(timer);
47
+ }
48
+ if (!response.ok) {
49
+ let body = '';
50
+ try {
51
+ body = await response.text();
52
+ }
53
+ catch {
54
+ // ignore
55
+ }
56
+ const msg = `Enrolled-repos request failed: HTTP ${response.status} — ${redactSecrets(body.slice(0, 200))}`;
57
+ logger.error(msg, { status: response.status });
58
+ throw new Error(msg);
59
+ }
60
+ let parsed;
61
+ try {
62
+ parsed = (await response.json());
63
+ }
64
+ catch (err) {
65
+ const msg = `Failed to parse my-repos response body: ${err instanceof Error ? err.message : String(err)}`;
66
+ logger.error(msg);
67
+ throw new Error(msg);
68
+ }
69
+ const repos = parsed['repos'];
70
+ if (!Array.isArray(repos)) {
71
+ const msg = 'Malformed my-repos response: missing repos array';
72
+ logger.error(msg, { parsedKeys: Object.keys(parsed) });
73
+ throw new Error(msg);
74
+ }
75
+ const validated = [];
76
+ for (const r of repos) {
77
+ if (!r || typeof r !== 'object')
78
+ continue;
79
+ const row = r;
80
+ if (typeof row.owner === 'string' &&
81
+ typeof row.repo === 'string' &&
82
+ typeof row.installationId === 'string') {
83
+ validated.push({
84
+ owner: row.owner,
85
+ repo: row.repo,
86
+ installationId: row.installationId,
87
+ });
88
+ }
89
+ }
90
+ logger.info('Enrolled repos fetched', { count: validated.length });
91
+ return validated;
92
+ }
@@ -21,6 +21,7 @@
21
21
  * 2 — platform not supported
22
22
  */
23
23
  import { AgentEnrollmentKeyringStore } from '../auth/agentEnrollmentKeyringStore.js';
24
+ import { fetchMyRepos } from './myReposClient.js';
24
25
  import { ensureRunner } from './binaryFetcher.js';
25
26
  import { type PoolDeps } from './pool.js';
26
27
  import type { EnsureRunnerOptions } from './binaryFetcher.js';
@@ -36,6 +37,8 @@ export interface StartOptions {
36
37
  readonly keyringStore?: AgentEnrollmentKeyringStore;
37
38
  /** Dependency injection for tests — avoids real network calls */
38
39
  readonly fetchFn?: typeof fetch;
40
+ /** Dependency injection for tests — avoids hitting the my-repos endpoint */
41
+ readonly fetchMyReposFn?: typeof fetchMyRepos;
39
42
  /** Dependency injection for tests — avoids real binary download */
40
43
  readonly ensureRunnerFn?: (opts?: EnsureRunnerOptions) => ReturnType<typeof ensureRunner>;
41
44
  /** Skip the LaunchAgent startup guard in tests */
@@ -24,6 +24,7 @@ import os from 'os';
24
24
  import { createLogger } from '../utils/logger.js';
25
25
  import { AgentEnrollmentKeyringStore } from '../auth/agentEnrollmentKeyringStore.js';
26
26
  import { fetchRunnerToken } from './tokenClient.js';
27
+ import { fetchMyRepos } from './myReposClient.js';
27
28
  import { ensureRunner, PLATFORM_NOT_SUPPORTED_MSG } from './binaryFetcher.js';
28
29
  import { configureRunner, runRunner, generateRunnerName, applyLaunchAgentStartupGuard, } from './supervisor.js';
29
30
  import { prepareCache, stopCache } from './cacheEnv.js';
@@ -71,46 +72,51 @@ async function resolveJwt(store) {
71
72
  return null;
72
73
  }
73
74
  /**
74
- * Read owner and repo from config. These are used to scope the runner-token
75
- * request. For v1.1 the agent is configured once per repo; the operator passes
76
- * these values in the agent config file or as CLI options.
75
+ * Resolve owner + repo for the current `start` invocation.
77
76
  *
78
- * Reads from:
79
- * 1. CLI opts (highest priority)
80
- * 2. BUILDHIVE_OWNER / BUILDHIVE_REPO env vars
81
- * 3. config file (if present)
77
+ * Group B / RI-08: zero-GH agent runtime. The developer never has to type
78
+ * BUILDHIVE_OWNER / BUILDHIVE_REPO. Resolution order:
82
79
  *
83
- * Returns null if neither owner nor repo can be resolved caller surfaces error.
80
+ * 1. CLI overrides (`--owner` + `--repo`) power-user / build-farm path.
81
+ * 2. BUILDHIVE_OWNER + BUILDHIVE_REPO env vars — retained as an override
82
+ * but NEVER advertised in docs / help / doctor output.
83
+ * 3. Server-side resolution via GET /api/runners/my-repos — the
84
+ * canonical zero-GH path. Picks the first enrolled repo in the
85
+ * server's deterministic alphabetical order.
86
+ *
87
+ * Returns null only when (a) the server returns zero enrolled repos AND
88
+ * (b) no CLI/env override is set — caller surfaces an actionable error
89
+ * pointing at `/admin/github`.
84
90
  */
85
- async function resolveOwnerRepo(opts) {
86
- // 1. CLI overrides
91
+ async function resolveOwnerRepo(opts, serverCtx) {
92
+ // 1. CLI overrides (highest priority; power-user / build-farm)
87
93
  if (opts.owner && opts.repo) {
88
- return { owner: opts.owner, repo: opts.repo };
94
+ return { owner: opts.owner, repo: opts.repo, extraRepos: [], source: 'cli' };
89
95
  }
90
- // 2. Environment variables
96
+ // 2. Environment variables (retained as override — never advertised)
91
97
  const ownerEnv = process.env.BUILDHIVE_OWNER?.trim();
92
98
  const repoEnv = process.env.BUILDHIVE_REPO?.trim();
93
99
  if (ownerEnv && repoEnv) {
94
- return { owner: ownerEnv, repo: repoEnv };
100
+ return { owner: ownerEnv, repo: repoEnv, extraRepos: [], source: 'env' };
95
101
  }
96
- // 3. Config file
102
+ // 3. Server-side resolution — the canonical zero-GH path.
103
+ let repos;
97
104
  try {
98
- const { loadConfig } = await import('../config/index.js');
99
- const cfg = await loadConfig();
100
- // AgentConfig may have these fields under a different name; the canonical
101
- // place for them in the config file is `githubOwner` + `githubRepo`.
102
- const cfgAny = cfg;
103
- const cfgOwner = typeof cfgAny.githubOwner === 'string' ? cfgAny.githubOwner : (opts.owner ?? '');
104
- const cfgRepo = typeof cfgAny.githubRepo === 'string' ? cfgAny.githubRepo : (opts.repo ?? '');
105
- if (cfgOwner && cfgRepo) {
106
- return { owner: cfgOwner, repo: cfgRepo };
107
- }
105
+ const fetchFn = opts.fetchMyReposFn ?? fetchMyRepos;
106
+ repos = await fetchFn({ platformUrl: serverCtx.platformUrl, jwt: serverCtx.jwt });
108
107
  }
109
- catch {
110
- // Config load failure is non-fatal at this step — fall through
108
+ catch (err) {
109
+ logger.error('Failed to fetch enrolled repos from /api/runners/my-repos', {
110
+ err: err instanceof Error ? err.message : String(err),
111
+ });
112
+ return null;
111
113
  }
112
- // 4. Not resolvable
113
- return null;
114
+ if (repos.length === 0) {
115
+ logger.error('Your tenant has no enrolled repos. Open /admin/github and enroll at least one repo, then run `buildhive-agent start` again.');
116
+ return null;
117
+ }
118
+ const [chosen, ...extras] = repos;
119
+ return { owner: chosen.owner, repo: chosen.repo, extraRepos: extras, source: 'server' };
114
120
  }
115
121
  /**
116
122
  * Main orchestrator. Called by cli.ts's `start` action.
@@ -144,14 +150,30 @@ export async function runStart(opts = {}) {
144
150
  const platformUrl = opts.platformUrl ?? jwtCreds.platformUrl;
145
151
  const { jwt } = jwtCreds;
146
152
  // ── Step 2: Resolve owner + repo ────────────────────────────────────────────
147
- const ownerRepo = await resolveOwnerRepo({ owner: opts.owner, repo: opts.repo });
153
+ // Group B / RI-08: the developer no longer needs to type
154
+ // BUILDHIVE_OWNER / BUILDHIVE_REPO. Resolution prefers (in order):
155
+ // 1. CLI overrides — power-user / build-farm
156
+ // 2. Env vars — retained as override but never advertised
157
+ // 3. /api/runners/my-repos — the canonical zero-GH path
158
+ const ownerRepo = await resolveOwnerRepo({ owner: opts.owner, repo: opts.repo, fetchMyReposFn: opts.fetchMyReposFn }, { platformUrl, jwt });
148
159
  if (!ownerRepo) {
149
- const msg = 'GitHub owner and repo are required. Set BUILDHIVE_OWNER and BUILDHIVE_REPO ' +
150
- 'or add githubOwner/githubRepo to your agent config file.';
160
+ const msg = 'No enrolled repos found for this agent. Open /admin/github in the BuildHive ' +
161
+ 'dashboard, enroll at least one repository, then run `buildhive-agent start` again.';
151
162
  logger.error(msg);
152
163
  return { exitCode: 1, message: msg };
153
164
  }
154
165
  const { owner, repo } = ownerRepo;
166
+ if (ownerRepo.source === 'server') {
167
+ logger.info('Resolved repo from /api/runners/my-repos', { owner, repo });
168
+ if (ownerRepo.extraRepos.length > 0) {
169
+ const more = ownerRepo.extraRepos.map((r) => `${r.owner}/${r.repo}`).join(', ');
170
+ logger.info(`Tenant has ${ownerRepo.extraRepos.length} additional enrolled repo(s): ${more}. ` +
171
+ `Ephemeral runner lifecycle will rotate through them on subsequent launches.`);
172
+ }
173
+ }
174
+ else {
175
+ logger.info(`Using ${ownerRepo.source} override for owner/repo`, { owner, repo });
176
+ }
155
177
  // ── Step 3: LaunchAgent startup guard (FB16131937) ──────────────────────────
156
178
  if (!opts.skipStartupGuard) {
157
179
  await applyLaunchAgentStartupGuard();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buildhive-agent",
3
- "version": "1.0.0-beta.10",
3
+ "version": "1.0.0-beta.11",
4
4
  "description": "BuildHive CI Agent - Distributed build execution agent",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",