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.
- package/dist/auth/joinCommand.d.ts +57 -0
- package/dist/auth/joinCommand.js +90 -4
- package/dist/cli.js +4 -2
- package/dist/doctor/index.d.ts +26 -8
- package/dist/doctor/index.js +51 -28
- package/dist/doctor/runChecks.d.ts +0 -16
- package/dist/doctor/runChecks.js +17 -163
- package/dist/doctor/runnerChecks.d.ts +54 -0
- package/dist/doctor/runnerChecks.js +212 -0
- package/dist/runner/binaryFetcher.d.ts +15 -4
- package/dist/runner/binaryFetcher.js +35 -9
- package/dist/runner/myReposClient.d.ts +29 -0
- package/dist/runner/myReposClient.js +92 -0
- package/dist/runner/startCommand.d.ts +3 -0
- package/dist/runner/startCommand.js +53 -31
- package/package.json +1 -1
|
@@ -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.
|
package/dist/auth/joinCommand.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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)
|
package/dist/doctor/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
}>;
|
package/dist/doctor/index.js
CHANGED
|
@@ -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
|
|
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,
|
|
16
|
+
import { checkNodeVersion, checkDockerInstalled, checkDockerDaemon, checkDockerSocketAccess, checkDiskSpace, checkWorkspaceWritable, checkServicePlistInstalled, checkServicePlistValid, checkServiceLoaded, checkServiceRunning, defaultDeps, } from './runChecks.js';
|
|
9
17
|
import { checkCacheEnabled } from './cacheCheck.js';
|
|
10
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
-
|
|
70
|
-
// Check
|
|
71
|
-
//
|
|
72
|
-
|
|
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
|
-
|
|
95
|
+
keyring = await checkKeyringPresent(effectiveRunnerDeps);
|
|
75
96
|
}
|
|
76
97
|
catch (err) {
|
|
77
|
-
|
|
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(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
results.push(
|
|
83
|
-
|
|
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
|
-
//
|
|
89
|
-
//
|
|
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
|
-
//
|
|
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(
|
|
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>;
|
package/dist/doctor/runChecks.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
79
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
102
|
+
// 3. Server-side resolution — the canonical zero-GH path.
|
|
103
|
+
let repos;
|
|
97
104
|
try {
|
|
98
|
-
const
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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 = '
|
|
150
|
-
'
|
|
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();
|