buildhive-agent 1.0.0-beta.10 → 1.0.0-beta.12
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 +80 -0
- package/dist/auth/joinCommand.js +145 -5
- 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 +62 -172
- 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 +32 -2
- package/dist/runner/startCommand.js +229 -101
- package/dist/runner/supervisor.d.ts +9 -0
- package/dist/runner/supervisor.js +82 -29
- 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 +2 -2
|
@@ -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
|
+
}
|
|
@@ -2,15 +2,58 @@
|
|
|
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
|
+
/**
|
|
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
|
+
}>;
|
|
56
|
+
}
|
|
14
57
|
export interface JoinOptions {
|
|
15
58
|
readonly token: string;
|
|
16
59
|
readonly platformUrl: string;
|
|
@@ -18,10 +61,47 @@ export interface JoinOptions {
|
|
|
18
61
|
readonly store?: ConstructorParameters<typeof AgentEnrollmentKeyringStore>[0];
|
|
19
62
|
/** Override fetch for tests. */
|
|
20
63
|
readonly fetchFn?: typeof fetch;
|
|
64
|
+
/**
|
|
65
|
+
* S-1: opt-out of the post-enroll LaunchAgent install (e.g. when the
|
|
66
|
+
* caller is running the agent in a container or build-farm context that
|
|
67
|
+
* manages its own supervision). Default: false → install runs.
|
|
68
|
+
*/
|
|
69
|
+
readonly skipServiceInstall?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Dependency injection for tests (avoids real launchctl bootstrap).
|
|
72
|
+
* When omitted, the real `service/serviceInstaller.installService` is
|
|
73
|
+
* used via dynamic import.
|
|
74
|
+
*/
|
|
75
|
+
readonly serviceInstaller?: ServiceInstallerInjection;
|
|
76
|
+
/**
|
|
77
|
+
* Override platform detection — tests set this to `'darwin'` regardless
|
|
78
|
+
* of the host OS so the LaunchAgent install path runs deterministically.
|
|
79
|
+
*/
|
|
80
|
+
readonly platformOverride?: NodeJS.Platform;
|
|
81
|
+
/**
|
|
82
|
+
* Override the resolved CLI entry path. Tests set this so the service
|
|
83
|
+
* installer doesn't try to realpath `process.argv[1]` (which points at
|
|
84
|
+
* the test runner, not at dist/cli.js).
|
|
85
|
+
*/
|
|
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>;
|
|
21
95
|
}
|
|
22
96
|
export interface JoinResult {
|
|
23
97
|
readonly exitCode: 0 | 1 | 2 | 3;
|
|
24
98
|
readonly message?: string;
|
|
99
|
+
/**
|
|
100
|
+
* S-1: whether the post-enroll LaunchAgent install was attempted and
|
|
101
|
+
* succeeded. Surfaced in the return for test assertions; the exit code
|
|
102
|
+
* is unchanged on install failure (the agent is still enrolled).
|
|
103
|
+
*/
|
|
104
|
+
readonly serviceInstalled?: boolean;
|
|
25
105
|
}
|
|
26
106
|
/**
|
|
27
107
|
* The main join logic. Exported for unit testing.
|