buildhive-agent 1.0.0-beta.11 → 1.0.0-beta.13
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/agentEnrollmentKeyringStore.d.ts +17 -3
- package/dist/auth/agentEnrollmentKeyringStore.js +104 -25
- package/dist/auth/credentialFileStore.d.ts +79 -0
- package/dist/auth/credentialFileStore.js +140 -0
- package/dist/auth/joinCommand.d.ts +23 -0
- package/dist/auth/joinCommand.js +59 -5
- package/dist/cacheServer/index.d.ts +1 -1
- package/dist/cli-handlers.d.ts +233 -0
- package/dist/cli-handlers.js +542 -0
- package/dist/cli.js +30 -421
- package/dist/doctor/runChecks.js +45 -9
- package/dist/index.d.ts +0 -2
- package/dist/index.js +5 -2
- package/dist/registration/apiClient.d.ts +10 -33
- package/dist/registration/apiClient.js +10 -99
- package/dist/runner/startCommand.d.ts +29 -2
- package/dist/runner/startCommand.js +290 -77
- package/dist/runner/supervisor.d.ts +17 -0
- package/dist/runner/supervisor.js +90 -33
- package/dist/service/plistGenerator.js +6 -1
- package/dist/service/serviceInstaller.d.ts +5 -0
- package/dist/service/serviceInstaller.js +6 -1
- package/package.json +4 -3
- package/dist/auth/loginCommand.d.ts +0 -24
- package/dist/auth/loginCommand.js +0 -62
- package/dist/auth/logoutCommand.d.ts +0 -16
- package/dist/auth/logoutCommand.js +0 -22
- package/dist/auth/whoamiCommand.d.ts +0 -14
- package/dist/auth/whoamiCommand.js +0 -28
- package/dist/registration/index.d.ts +0 -47
- package/dist/registration/index.js +0 -143
- package/dist/registration/types.d.ts +0 -43
- package/dist/registration/types.js +0 -8
- package/dist/runner/cacheStatsReporter.d.ts +0 -46
- package/dist/runner/cacheStatsReporter.js +0 -92
- package/dist/types.d.ts +0 -48
- package/dist/types.js +0 -6
- package/dist/utils/capabilities.d.ts +0 -22
- package/dist/utils/capabilities.js +0 -199
- package/dist/utils/sdkScanner.d.ts +0 -104
- package/dist/utils/sdkScanner.js +0 -458
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* agent-enrollment.platform_url — bound at enroll; refuses cross-instance reuse
|
|
15
15
|
*/
|
|
16
16
|
import type { SecretStore } from '../security/secretStore.js';
|
|
17
|
+
import { CredentialFileStore } from './credentialFileStore.js';
|
|
17
18
|
import { PlatformUrlMismatchError } from './types.js';
|
|
18
19
|
export declare const AGENT_ENROLLMENT_KEY_PREFIX = "agent-enrollment.";
|
|
19
20
|
export declare const KEY_JWT = "agent-enrollment.jwt";
|
|
@@ -31,6 +32,12 @@ export interface AgentEnrollmentCredentials {
|
|
|
31
32
|
export interface AgentEnrollmentKeyringStoreOptions {
|
|
32
33
|
/** Override the SecretStore (tests + environments without OS keyring). */
|
|
33
34
|
readonly store?: SecretStore;
|
|
35
|
+
/**
|
|
36
|
+
* Override the 0600 credential-file mirror (row 21b / F2 daemon fallback).
|
|
37
|
+
* Tests inject a fake; production uses the real `~/.buildhive/` file.
|
|
38
|
+
* Pass `null` to disable the file mirror entirely (keyring-only).
|
|
39
|
+
*/
|
|
40
|
+
readonly fileStore?: CredentialFileStore | null;
|
|
34
41
|
}
|
|
35
42
|
/**
|
|
36
43
|
* Custom error thrown when the stored platform_url doesn't match the current
|
|
@@ -42,6 +49,8 @@ export declare class AgentPlatformUrlMismatchError extends PlatformUrlMismatchEr
|
|
|
42
49
|
}
|
|
43
50
|
export declare class AgentEnrollmentKeyringStore {
|
|
44
51
|
private readonly store;
|
|
52
|
+
/** 0600 file mirror for the launchd daemon; null when explicitly disabled. */
|
|
53
|
+
private readonly fileStore;
|
|
45
54
|
constructor(opts?: AgentEnrollmentKeyringStoreOptions);
|
|
46
55
|
/** Throws KeyringUnavailableError if the OS keyring is unreachable. */
|
|
47
56
|
static assertAvailable(): Promise<void>;
|
|
@@ -63,16 +72,21 @@ export declare class AgentEnrollmentKeyringStore {
|
|
|
63
72
|
* Throws NotLoggedInError if any required key is missing.
|
|
64
73
|
*/
|
|
65
74
|
readAll(): Promise<AgentEnrollmentCredentials>;
|
|
75
|
+
/**
|
|
76
|
+
* Read the 0600 file mirror, swallowing any error to null. Centralised so
|
|
77
|
+
* every read path (readJwt / readAll / hasEnrollment) shares one fallback.
|
|
78
|
+
*/
|
|
79
|
+
private readFromFileMirror;
|
|
66
80
|
/**
|
|
67
81
|
* Cross-platform-url check. Refuses to use credentials minted against a
|
|
68
82
|
* different BuildHive instance.
|
|
69
83
|
*/
|
|
70
84
|
assertPlatformUrlMatches(currentPlatformUrl: string): Promise<void>;
|
|
71
|
-
/** Returns true if
|
|
85
|
+
/** Returns true if an enrollment JWT exists in the keyring OR the file mirror. */
|
|
72
86
|
hasEnrollment(): Promise<boolean>;
|
|
73
87
|
/**
|
|
74
|
-
* Delete all 5 `agent-enrollment.*` keys. Idempotent.
|
|
75
|
-
* Called by
|
|
88
|
+
* Delete all 5 `agent-enrollment.*` keys AND the 0600 file mirror. Idempotent.
|
|
89
|
+
* Called by `buildhive-agent logout` (row 21b / F4) to fully de-enroll.
|
|
76
90
|
*/
|
|
77
91
|
clear(): Promise<void>;
|
|
78
92
|
}
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
* agent-enrollment.platform_url — bound at enroll; refuses cross-instance reuse
|
|
15
15
|
*/
|
|
16
16
|
import { KeyringSecretStore } from '../security/keyringSecretStore.js';
|
|
17
|
+
import { CredentialFileStore } from './credentialFileStore.js';
|
|
18
|
+
import { createLogger } from '../utils/logger.js';
|
|
17
19
|
import { KeyringUnavailableError, NotLoggedInError, PlatformUrlMismatchError, } from './types.js';
|
|
20
|
+
const logger = createLogger('auth.agentEnrollmentKeyringStore');
|
|
18
21
|
export const AGENT_ENROLLMENT_KEY_PREFIX = 'agent-enrollment.';
|
|
19
22
|
export const KEY_JWT = `${AGENT_ENROLLMENT_KEY_PREFIX}jwt`;
|
|
20
23
|
export const KEY_JWT_EXP = `${AGENT_ENROLLMENT_KEY_PREFIX}jwt_exp`;
|
|
@@ -44,8 +47,29 @@ export class AgentPlatformUrlMismatchError extends PlatformUrlMismatchError {
|
|
|
44
47
|
}
|
|
45
48
|
export class AgentEnrollmentKeyringStore {
|
|
46
49
|
store;
|
|
50
|
+
/** 0600 file mirror for the launchd daemon; null when explicitly disabled. */
|
|
51
|
+
fileStore;
|
|
47
52
|
constructor(opts = {}) {
|
|
48
53
|
this.store = opts.store ?? new KeyringSecretStore();
|
|
54
|
+
// File-mirror resolution (row 21b / F2):
|
|
55
|
+
// - explicit `null` → disabled (keyring-only)
|
|
56
|
+
// - explicit CredentialFileStore → use it
|
|
57
|
+
// - omitted + real keyring → real `~/.buildhive/` mirror (production)
|
|
58
|
+
// - omitted + INJECTED keyring (tests) → disabled, so unit tests never
|
|
59
|
+
// read/write the real user home. A test exercising the mirror must
|
|
60
|
+
// inject its own `fileStore` explicitly.
|
|
61
|
+
if (opts.fileStore === null) {
|
|
62
|
+
this.fileStore = null;
|
|
63
|
+
}
|
|
64
|
+
else if (opts.fileStore) {
|
|
65
|
+
this.fileStore = opts.fileStore;
|
|
66
|
+
}
|
|
67
|
+
else if (opts.store) {
|
|
68
|
+
this.fileStore = null;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
this.fileStore = new CredentialFileStore();
|
|
72
|
+
}
|
|
49
73
|
}
|
|
50
74
|
/** Throws KeyringUnavailableError if the OS keyring is unreachable. */
|
|
51
75
|
static async assertAvailable() {
|
|
@@ -75,6 +99,21 @@ export class AgentEnrollmentKeyringStore {
|
|
|
75
99
|
await this.clear().catch(() => undefined);
|
|
76
100
|
throw err;
|
|
77
101
|
}
|
|
102
|
+
// Row 21b / F2: mirror to the 0600 file so the launchd daemon (which
|
|
103
|
+
// cannot read the interactive login keychain) can resolve the JWT.
|
|
104
|
+
// The keychain write above is the source of truth; a file-mirror failure
|
|
105
|
+
// is non-fatal here (the agent is enrolled for foreground use) but it
|
|
106
|
+
// leaves the daemon unable to start — `join`'s post-bootstrap self-check
|
|
107
|
+
// (Fix D) and the doctor will surface that loudly rather than silently.
|
|
108
|
+
if (this.fileStore) {
|
|
109
|
+
try {
|
|
110
|
+
await this.fileStore.write(creds);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
logger.warn('Could not write the 0600 credential mirror — the background daemon may ' +
|
|
114
|
+
'not be able to read the JWT. Foreground `buildhive-agent start` is unaffected.', { err: err instanceof Error ? err.message : String(err) });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
78
117
|
}
|
|
79
118
|
/**
|
|
80
119
|
* Read the stored JWT + its cached exp. Throws NotLoggedInError if absent
|
|
@@ -85,13 +124,17 @@ export class AgentEnrollmentKeyringStore {
|
|
|
85
124
|
this.store.getSecret(KEY_JWT),
|
|
86
125
|
this.store.getSecret(KEY_JWT_EXP),
|
|
87
126
|
]);
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
127
|
+
if (token && expStr) {
|
|
128
|
+
const exp = Number(expStr);
|
|
129
|
+
return { jwt: token, expiresAtUnix: Number.isFinite(exp) ? exp : 0 };
|
|
130
|
+
}
|
|
131
|
+
// Row 21b / F2: keychain returned nothing (the daemon case) — fall back
|
|
132
|
+
// to the 0600 file mirror before declaring "not enrolled".
|
|
133
|
+
const fromFile = await this.readFromFileMirror();
|
|
134
|
+
if (fromFile) {
|
|
135
|
+
return { jwt: fromFile.jwt, expiresAtUnix: fromFile.jwtExpiresAtUnix };
|
|
136
|
+
}
|
|
137
|
+
throw new NotLoggedInError();
|
|
95
138
|
}
|
|
96
139
|
/**
|
|
97
140
|
* Read the full stored credentials (for display or row-17b supervisor).
|
|
@@ -105,43 +148,79 @@ export class AgentEnrollmentKeyringStore {
|
|
|
105
148
|
this.store.getSecret(KEY_TENANT_ID),
|
|
106
149
|
this.store.getSecret(KEY_PLATFORM_URL),
|
|
107
150
|
]);
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
151
|
+
if (jwt && expStr && agentId && tenantId && platformUrl) {
|
|
152
|
+
const exp = Number(expStr);
|
|
153
|
+
return {
|
|
154
|
+
jwt,
|
|
155
|
+
jwtExpiresAtUnix: Number.isFinite(exp) ? exp : 0,
|
|
156
|
+
agentId,
|
|
157
|
+
tenantId,
|
|
158
|
+
platformUrl,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
// Row 21b / F2: the launchd daemon's keychain read comes back empty.
|
|
162
|
+
// Fall back to the 0600 file mirror written by `join` before throwing.
|
|
163
|
+
const fromFile = await this.readFromFileMirror();
|
|
164
|
+
if (fromFile)
|
|
165
|
+
return fromFile;
|
|
166
|
+
throw new NotLoggedInError();
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Read the 0600 file mirror, swallowing any error to null. Centralised so
|
|
170
|
+
* every read path (readJwt / readAll / hasEnrollment) shares one fallback.
|
|
171
|
+
*/
|
|
172
|
+
async readFromFileMirror() {
|
|
173
|
+
if (!this.fileStore)
|
|
174
|
+
return null;
|
|
175
|
+
try {
|
|
176
|
+
const creds = await this.fileStore.read();
|
|
177
|
+
if (creds) {
|
|
178
|
+
logger.info('Resolved agent credentials from the 0600 file mirror (keychain unavailable in this session)');
|
|
179
|
+
}
|
|
180
|
+
return creds;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
119
185
|
}
|
|
120
186
|
/**
|
|
121
187
|
* Cross-platform-url check. Refuses to use credentials minted against a
|
|
122
188
|
* different BuildHive instance.
|
|
123
189
|
*/
|
|
124
190
|
async assertPlatformUrlMatches(currentPlatformUrl) {
|
|
125
|
-
|
|
126
|
-
if (!stored)
|
|
127
|
-
|
|
191
|
+
let stored = await this.store.getSecret(KEY_PLATFORM_URL);
|
|
192
|
+
if (!stored) {
|
|
193
|
+
// Row 21b / F2: keep the cross-instance guard working in the daemon
|
|
194
|
+
// session, where the keychain read comes back empty — fall back to the
|
|
195
|
+
// 0600 file mirror before declaring "not enrolled".
|
|
196
|
+
const fromFile = await this.readFromFileMirror();
|
|
197
|
+
if (!fromFile)
|
|
198
|
+
throw new NotLoggedInError();
|
|
199
|
+
stored = fromFile.platformUrl;
|
|
200
|
+
}
|
|
128
201
|
if (normalizeUrl(stored) !== normalizeUrl(currentPlatformUrl)) {
|
|
129
202
|
throw new AgentPlatformUrlMismatchError(stored, currentPlatformUrl);
|
|
130
203
|
}
|
|
131
204
|
}
|
|
132
|
-
/** Returns true if
|
|
205
|
+
/** Returns true if an enrollment JWT exists in the keyring OR the file mirror. */
|
|
133
206
|
async hasEnrollment() {
|
|
134
207
|
const jwt = await this.store.getSecret(KEY_JWT);
|
|
135
|
-
|
|
208
|
+
if (jwt !== null && jwt.length > 0)
|
|
209
|
+
return true;
|
|
210
|
+
const fromFile = await this.readFromFileMirror();
|
|
211
|
+
return fromFile !== null;
|
|
136
212
|
}
|
|
137
213
|
/**
|
|
138
|
-
* Delete all 5 `agent-enrollment.*` keys. Idempotent.
|
|
139
|
-
* Called by
|
|
214
|
+
* Delete all 5 `agent-enrollment.*` keys AND the 0600 file mirror. Idempotent.
|
|
215
|
+
* Called by `buildhive-agent logout` (row 21b / F4) to fully de-enroll.
|
|
140
216
|
*/
|
|
141
217
|
async clear() {
|
|
142
218
|
for (const k of ALL_KEYS) {
|
|
143
219
|
await this.store.deleteSecret(k).catch(() => false);
|
|
144
220
|
}
|
|
221
|
+
if (this.fileStore) {
|
|
222
|
+
await this.fileStore.delete().catch(() => undefined);
|
|
223
|
+
}
|
|
145
224
|
}
|
|
146
225
|
}
|
|
147
226
|
function normalizeUrl(u) {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* credentialFileStore — 0600 on-disk fallback for the agent-enrollment
|
|
3
|
+
* credentials, so the **launchd-managed daemon** can read its JWT.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this exists (row 21b / F2, 2026-06-02) ───────────────────────────────
|
|
6
|
+
* The 2026-06-01 verification walk found the LaunchAgent daemon crashlooping
|
|
7
|
+
* with `last exit code = 1` and empty logs. Root cause (proven in
|
|
8
|
+
* docs/ops/diagnosis-f3-agent-daemon-2026-06-02.html): the macOS Keychain item
|
|
9
|
+
* holding the JWT is created by the *foreground* `join` process (Terminal
|
|
10
|
+
* security session). The launchd-spawned daemon runs in a different security
|
|
11
|
+
* session (`SessionCreate=true`), so `@napi-rs/keyring`'s `getPassword()`
|
|
12
|
+
* returns `null` for it (silent access-deny). The daemon then reads "no JWT" →
|
|
13
|
+
* "Not enrolled" → exits 1 → launchd respawns → silent crashloop.
|
|
14
|
+
*
|
|
15
|
+
* GitHub's own `actions/runner` `svc.sh` stores its `.credentials` as files in
|
|
16
|
+
* the runner directory for exactly this reason — a launchd/systemd service
|
|
17
|
+
* cannot rely on the interactive login keychain.
|
|
18
|
+
*
|
|
19
|
+
* ── Design ───────────────────────────────────────────────────────────────────
|
|
20
|
+
* The OS keyring stays the PRIMARY store (used by foreground `start`, where it
|
|
21
|
+
* works and is the more-secure option). This file is a daemon-readable MIRROR:
|
|
22
|
+
* `join` writes both; the resolver falls back to the file only when the keyring
|
|
23
|
+
* returns nothing (the daemon case).
|
|
24
|
+
*
|
|
25
|
+
* Security:
|
|
26
|
+
* - File mode 0600 (owner read/write only), parent dir 0700.
|
|
27
|
+
* - Lives under the user's own `~/.buildhive/` — same trust boundary as the
|
|
28
|
+
* actions/runner `.credentials` file and the workspaces dir.
|
|
29
|
+
* - Never packaged (runtime state in $HOME, not in the npm tarball).
|
|
30
|
+
*
|
|
31
|
+
* This module is pure-where-possible: fs + homedir are injectable for tests.
|
|
32
|
+
*/
|
|
33
|
+
import type { AgentEnrollmentCredentials } from './agentEnrollmentKeyringStore.js';
|
|
34
|
+
/** Basename of the credential mirror file under `~/.buildhive/`. */
|
|
35
|
+
export declare const CREDENTIAL_FILE_NAME = "agent-enrollment.cred";
|
|
36
|
+
/** Injectable fs surface (subset of node:fs/promises we use). */
|
|
37
|
+
export interface CredentialFileFs {
|
|
38
|
+
readonly mkdir: (path: string, opts: {
|
|
39
|
+
recursive: boolean;
|
|
40
|
+
mode?: number;
|
|
41
|
+
}) => Promise<string | undefined>;
|
|
42
|
+
readonly writeFile: (path: string, data: string, opts: {
|
|
43
|
+
encoding: 'utf8';
|
|
44
|
+
mode?: number;
|
|
45
|
+
}) => Promise<void>;
|
|
46
|
+
readonly chmod: (path: string, mode: number) => Promise<void>;
|
|
47
|
+
readonly rename: (from: string, to: string) => Promise<void>;
|
|
48
|
+
readonly readFile: (path: string, encoding: 'utf8') => Promise<string>;
|
|
49
|
+
readonly unlink: (path: string) => Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
export interface CredentialFileStoreOptions {
|
|
52
|
+
/** Override the home directory (tests + multi-user). */
|
|
53
|
+
readonly homeDir?: string;
|
|
54
|
+
/** Override the fs surface (tests). */
|
|
55
|
+
readonly fs?: CredentialFileFs;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Reads/writes the agent-enrollment credentials as a single 0600 JSON file.
|
|
59
|
+
*/
|
|
60
|
+
export declare class CredentialFileStore {
|
|
61
|
+
private readonly fs;
|
|
62
|
+
private readonly dir;
|
|
63
|
+
private readonly filePath;
|
|
64
|
+
constructor(opts?: CredentialFileStoreOptions);
|
|
65
|
+
/** Absolute path to the credential file (for diagnostics + doctor). */
|
|
66
|
+
get path(): string;
|
|
67
|
+
/**
|
|
68
|
+
* Atomically write the credential mirror with 0600 perms.
|
|
69
|
+
* tmp-file → chmod 0600 → rename avoids a torn read by the daemon.
|
|
70
|
+
*/
|
|
71
|
+
write(creds: AgentEnrollmentCredentials): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Read the credential mirror. Returns null when absent or malformed — never
|
|
74
|
+
* throws, so the resolver can cleanly fall through to "not enrolled".
|
|
75
|
+
*/
|
|
76
|
+
read(): Promise<AgentEnrollmentCredentials | null>;
|
|
77
|
+
/** Delete the credential mirror. Idempotent (ENOENT is not an error). */
|
|
78
|
+
delete(): Promise<void>;
|
|
79
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* credentialFileStore — 0600 on-disk fallback for the agent-enrollment
|
|
3
|
+
* credentials, so the **launchd-managed daemon** can read its JWT.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this exists (row 21b / F2, 2026-06-02) ───────────────────────────────
|
|
6
|
+
* The 2026-06-01 verification walk found the LaunchAgent daemon crashlooping
|
|
7
|
+
* with `last exit code = 1` and empty logs. Root cause (proven in
|
|
8
|
+
* docs/ops/diagnosis-f3-agent-daemon-2026-06-02.html): the macOS Keychain item
|
|
9
|
+
* holding the JWT is created by the *foreground* `join` process (Terminal
|
|
10
|
+
* security session). The launchd-spawned daemon runs in a different security
|
|
11
|
+
* session (`SessionCreate=true`), so `@napi-rs/keyring`'s `getPassword()`
|
|
12
|
+
* returns `null` for it (silent access-deny). The daemon then reads "no JWT" →
|
|
13
|
+
* "Not enrolled" → exits 1 → launchd respawns → silent crashloop.
|
|
14
|
+
*
|
|
15
|
+
* GitHub's own `actions/runner` `svc.sh` stores its `.credentials` as files in
|
|
16
|
+
* the runner directory for exactly this reason — a launchd/systemd service
|
|
17
|
+
* cannot rely on the interactive login keychain.
|
|
18
|
+
*
|
|
19
|
+
* ── Design ───────────────────────────────────────────────────────────────────
|
|
20
|
+
* The OS keyring stays the PRIMARY store (used by foreground `start`, where it
|
|
21
|
+
* works and is the more-secure option). This file is a daemon-readable MIRROR:
|
|
22
|
+
* `join` writes both; the resolver falls back to the file only when the keyring
|
|
23
|
+
* returns nothing (the daemon case).
|
|
24
|
+
*
|
|
25
|
+
* Security:
|
|
26
|
+
* - File mode 0600 (owner read/write only), parent dir 0700.
|
|
27
|
+
* - Lives under the user's own `~/.buildhive/` — same trust boundary as the
|
|
28
|
+
* actions/runner `.credentials` file and the workspaces dir.
|
|
29
|
+
* - Never packaged (runtime state in $HOME, not in the npm tarball).
|
|
30
|
+
*
|
|
31
|
+
* This module is pure-where-possible: fs + homedir are injectable for tests.
|
|
32
|
+
*/
|
|
33
|
+
import { homedir } from 'node:os';
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
import { promises as defaultFsPromises } from 'node:fs';
|
|
36
|
+
/** Basename of the credential mirror file under `~/.buildhive/`. */
|
|
37
|
+
export const CREDENTIAL_FILE_NAME = 'agent-enrollment.cred';
|
|
38
|
+
const defaultFs = {
|
|
39
|
+
mkdir: (p, o) => defaultFsPromises.mkdir(p, o),
|
|
40
|
+
writeFile: (p, d, o) => defaultFsPromises.writeFile(p, d, o),
|
|
41
|
+
chmod: (p, m) => defaultFsPromises.chmod(p, m),
|
|
42
|
+
rename: (a, b) => defaultFsPromises.rename(a, b),
|
|
43
|
+
readFile: (p, e) => defaultFsPromises.readFile(p, e),
|
|
44
|
+
unlink: (p) => defaultFsPromises.unlink(p),
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Reads/writes the agent-enrollment credentials as a single 0600 JSON file.
|
|
48
|
+
*/
|
|
49
|
+
export class CredentialFileStore {
|
|
50
|
+
fs;
|
|
51
|
+
dir;
|
|
52
|
+
filePath;
|
|
53
|
+
constructor(opts = {}) {
|
|
54
|
+
this.fs = opts.fs ?? defaultFs;
|
|
55
|
+
const home = opts.homeDir ?? homedir();
|
|
56
|
+
this.dir = join(home, '.buildhive');
|
|
57
|
+
this.filePath = join(this.dir, CREDENTIAL_FILE_NAME);
|
|
58
|
+
}
|
|
59
|
+
/** Absolute path to the credential file (for diagnostics + doctor). */
|
|
60
|
+
get path() {
|
|
61
|
+
return this.filePath;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Atomically write the credential mirror with 0600 perms.
|
|
65
|
+
* tmp-file → chmod 0600 → rename avoids a torn read by the daemon.
|
|
66
|
+
*/
|
|
67
|
+
async write(creds) {
|
|
68
|
+
await this.fs.mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
69
|
+
const tmpPath = `${this.filePath}.tmp.${process.pid}`;
|
|
70
|
+
const payload = JSON.stringify({
|
|
71
|
+
jwt: creds.jwt,
|
|
72
|
+
jwtExpiresAtUnix: creds.jwtExpiresAtUnix,
|
|
73
|
+
agentId: creds.agentId,
|
|
74
|
+
tenantId: creds.tenantId,
|
|
75
|
+
platformUrl: creds.platformUrl,
|
|
76
|
+
});
|
|
77
|
+
await this.fs.writeFile(tmpPath, payload, { encoding: 'utf8', mode: 0o600 });
|
|
78
|
+
try {
|
|
79
|
+
// Belt-and-suspenders over the writeFile mode (umask can clear bits).
|
|
80
|
+
await this.fs.chmod(tmpPath, 0o600);
|
|
81
|
+
await this.fs.rename(tmpPath, this.filePath);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
// Don't leave a tmp file holding the JWT on a chmod/rename failure
|
|
85
|
+
// (ENOSPC, cross-device, mid-op crash). Best-effort cleanup, re-throw.
|
|
86
|
+
await this.fs.unlink(tmpPath).catch(() => undefined);
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Read the credential mirror. Returns null when absent or malformed — never
|
|
92
|
+
* throws, so the resolver can cleanly fall through to "not enrolled".
|
|
93
|
+
*/
|
|
94
|
+
async read() {
|
|
95
|
+
let raw;
|
|
96
|
+
try {
|
|
97
|
+
raw = await this.fs.readFile(this.filePath, 'utf8');
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null; // ENOENT or unreadable
|
|
101
|
+
}
|
|
102
|
+
let parsed;
|
|
103
|
+
try {
|
|
104
|
+
parsed = JSON.parse(raw);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
if (!parsed || typeof parsed !== 'object')
|
|
110
|
+
return null;
|
|
111
|
+
const o = parsed;
|
|
112
|
+
if (typeof o.jwt !== 'string' ||
|
|
113
|
+
typeof o.agentId !== 'string' ||
|
|
114
|
+
typeof o.tenantId !== 'string' ||
|
|
115
|
+
typeof o.platformUrl !== 'string' ||
|
|
116
|
+
o.jwt.length === 0 ||
|
|
117
|
+
o.agentId.length === 0 ||
|
|
118
|
+
o.tenantId.length === 0 ||
|
|
119
|
+
o.platformUrl.length === 0) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const exp = typeof o.jwtExpiresAtUnix === 'number' ? o.jwtExpiresAtUnix : 0;
|
|
123
|
+
return {
|
|
124
|
+
jwt: o.jwt,
|
|
125
|
+
jwtExpiresAtUnix: Number.isFinite(exp) ? exp : 0,
|
|
126
|
+
agentId: o.agentId,
|
|
127
|
+
tenantId: o.tenantId,
|
|
128
|
+
platformUrl: o.platformUrl,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** Delete the credential mirror. Idempotent (ENOENT is not an error). */
|
|
132
|
+
async delete() {
|
|
133
|
+
try {
|
|
134
|
+
await this.fs.unlink(this.filePath);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// best-effort — file may already be gone
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -38,6 +38,21 @@ export interface ServiceInstallerInjection {
|
|
|
38
38
|
paths: ServicePaths;
|
|
39
39
|
bootstrapStdout: string;
|
|
40
40
|
}>;
|
|
41
|
+
/**
|
|
42
|
+
* Row 21b / Fix D — read the launchd service health after bootstrap so
|
|
43
|
+
* `join` can self-verify the daemon actually started (instead of silently
|
|
44
|
+
* installing a crashlooping daemon). Optional: when omitted, the real
|
|
45
|
+
* `getServiceStatus` is used via dynamic import.
|
|
46
|
+
*/
|
|
47
|
+
readonly getServiceStatus?: (opts: {
|
|
48
|
+
mode: 'user' | 'system';
|
|
49
|
+
label?: string;
|
|
50
|
+
homeDir?: string;
|
|
51
|
+
}) => Promise<{
|
|
52
|
+
loaded: boolean;
|
|
53
|
+
state: 'running' | 'waiting' | 'unknown' | null;
|
|
54
|
+
lastExitCode: number | null;
|
|
55
|
+
}>;
|
|
41
56
|
}
|
|
42
57
|
export interface JoinOptions {
|
|
43
58
|
readonly token: string;
|
|
@@ -69,6 +84,14 @@ export interface JoinOptions {
|
|
|
69
84
|
* the test runner, not at dist/cli.js).
|
|
70
85
|
*/
|
|
71
86
|
readonly cliEntryPathOverride?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Row 21b / Fix D — how long to wait after bootstrap before reading the
|
|
89
|
+
* daemon's launchd health (gives RunAtLoad + the FB16131937 startup guard
|
|
90
|
+
* time to fire). Default 3000ms; tests pass 0.
|
|
91
|
+
*/
|
|
92
|
+
readonly verifyDelayMs?: number;
|
|
93
|
+
/** Sleep injection for the Fix-D verify delay (tests pass a no-op). */
|
|
94
|
+
readonly sleepFn?: (ms: number) => Promise<void>;
|
|
72
95
|
}
|
|
73
96
|
export interface JoinResult {
|
|
74
97
|
readonly exitCode: 0 | 1 | 2 | 3;
|
package/dist/auth/joinCommand.js
CHANGED
|
@@ -195,19 +195,24 @@ export async function runJoin(opts) {
|
|
|
195
195
|
}
|
|
196
196
|
// 8. Print enrollment success.
|
|
197
197
|
const agentIdShort = agentId.slice(0, 8);
|
|
198
|
-
|
|
198
|
+
// teamName is cosmetic; guard against an older/forward backend that omits it.
|
|
199
|
+
console.log(`✓ Agent enrolled in team "${teamName ?? '(unknown)'}" as agent ${agentIdShort}…`);
|
|
199
200
|
console.log(`✓ JWT stored in OS keyring (expires ${expiresDate}).`);
|
|
200
201
|
// 9. S-1: Auto-install LaunchAgent so the dev never thinks about persistence.
|
|
201
202
|
// Idempotent — re-running `join` cleanly upgrades the plist (the underlying
|
|
202
203
|
// installer uses an atomic tmp-file → rename).
|
|
204
|
+
// Row 21b / Fix D: maybeInstallLaunchAgent ALSO self-verifies the daemon
|
|
205
|
+
// actually came up; it returns false (with a loud message) when the daemon
|
|
206
|
+
// installed but crashed, so we never claim "set and forget" falsely.
|
|
203
207
|
const serviceInstalled = await maybeInstallLaunchAgent(opts);
|
|
204
208
|
if (serviceInstalled) {
|
|
205
|
-
console.log('✓ Agent installed and will auto-start on every login. ' +
|
|
209
|
+
console.log('✓ Agent installed and running — it will auto-start on every login. ' +
|
|
206
210
|
'Inspect logs with `buildhive-agent logs`.');
|
|
207
211
|
}
|
|
208
212
|
else {
|
|
209
|
-
// Either non-macOS, opt-out,
|
|
210
|
-
//
|
|
213
|
+
// Either non-macOS, opt-out, install failed, OR the daemon did not start
|
|
214
|
+
// cleanly (Fix D). maybeInstallLaunchAgent already printed the specific
|
|
215
|
+
// reason; give the foreground fallback the user can always run.
|
|
211
216
|
console.log('Run `buildhive-agent start` to begin picking up workflow jobs.');
|
|
212
217
|
}
|
|
213
218
|
return { exitCode: 0, serviceInstalled };
|
|
@@ -265,7 +270,6 @@ async function maybeInstallLaunchAgent(opts) {
|
|
|
265
270
|
}
|
|
266
271
|
try {
|
|
267
272
|
await installFn({ mode: 'user', cliEntryPath });
|
|
268
|
-
return true;
|
|
269
273
|
}
|
|
270
274
|
catch (err) {
|
|
271
275
|
// Already-loaded-service is a benign case — installService's atomic
|
|
@@ -277,6 +281,56 @@ async function maybeInstallLaunchAgent(opts) {
|
|
|
277
281
|
'or run the agent in the foreground via `buildhive-agent start`.');
|
|
278
282
|
return false;
|
|
279
283
|
}
|
|
284
|
+
// Row 21b / Fix D — self-verify the daemon actually started. The 2026-06-01
|
|
285
|
+
// walk installed a daemon that crashlooped silently (last exit code = 1) yet
|
|
286
|
+
// join reported success. Now we wait, read launchd health, and fail LOUD.
|
|
287
|
+
return verifyDaemonStarted(opts);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Fix D: after bootstrap, give launchd's RunAtLoad + the FB16131937 startup
|
|
291
|
+
* guard a moment to fire, then read the daemon's health. Returns true only when
|
|
292
|
+
* the daemon is genuinely up (loaded, not crashed). On a crash it prints an
|
|
293
|
+
* actionable message and returns false so `join` recommends the foreground
|
|
294
|
+
* fallback instead of claiming "set and forget".
|
|
295
|
+
*/
|
|
296
|
+
async function verifyDaemonStarted(opts) {
|
|
297
|
+
const sleep = opts.sleepFn ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
298
|
+
const delay = opts.verifyDelayMs ?? 3000;
|
|
299
|
+
// Resolve the status reader (injected for tests, real one otherwise).
|
|
300
|
+
let statusFn;
|
|
301
|
+
if (opts.serviceInstaller?.getServiceStatus) {
|
|
302
|
+
statusFn = opts.serviceInstaller.getServiceStatus;
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
try {
|
|
306
|
+
const mod = await import('../service/serviceInstaller.js');
|
|
307
|
+
statusFn = mod.getServiceStatus;
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// Can't verify — assume installed (don't block enrollment), but say so.
|
|
311
|
+
console.error('[join] Service installed; could not verify daemon health automatically.');
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
await sleep(delay);
|
|
316
|
+
let status;
|
|
317
|
+
try {
|
|
318
|
+
status = await statusFn({ mode: 'user' });
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
console.error('[join] Service installed; could not read daemon health (launchctl print failed).');
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
const crashed = status.lastExitCode !== null && status.lastExitCode !== 0;
|
|
325
|
+
if (!status.loaded || crashed) {
|
|
326
|
+
console.error('[join] ⚠ The background service was installed but did NOT start cleanly' +
|
|
327
|
+
(crashed ? ` (last exit code = ${status.lastExitCode}).` : ' (not loaded).'));
|
|
328
|
+
console.error('[join] Diagnose with `buildhive-agent doctor`, read the log with ' +
|
|
329
|
+
'`buildhive-agent logs` (~/.buildhive/logs/buildhive-agent.log), ' +
|
|
330
|
+
'or run `buildhive-agent start` in the foreground for now.');
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
return true;
|
|
280
334
|
}
|
|
281
335
|
/** Pull tenant_id from JWT payload without signature verification. */
|
|
282
336
|
function extractTenantId(jwtToken) {
|
|
@@ -33,7 +33,7 @@ export interface CacheServerOptions {
|
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
35
|
* Hit/miss/bytes snapshot of the cache server's runtime counters.
|
|
36
|
-
* Row 18b Wave 3 / S6 —
|
|
36
|
+
* Row 18b Wave 3 / S6 — exposed via the cache server's getStats().
|
|
37
37
|
*/
|
|
38
38
|
export interface CacheServerStats {
|
|
39
39
|
/** Number of GET /cache requests that returned 200 (cache hit). */
|