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.
Files changed (41) hide show
  1. package/dist/auth/agentEnrollmentKeyringStore.d.ts +17 -3
  2. package/dist/auth/agentEnrollmentKeyringStore.js +104 -25
  3. package/dist/auth/credentialFileStore.d.ts +79 -0
  4. package/dist/auth/credentialFileStore.js +140 -0
  5. package/dist/auth/joinCommand.d.ts +23 -0
  6. package/dist/auth/joinCommand.js +59 -5
  7. package/dist/cacheServer/index.d.ts +1 -1
  8. package/dist/cli-handlers.d.ts +233 -0
  9. package/dist/cli-handlers.js +542 -0
  10. package/dist/cli.js +30 -421
  11. package/dist/doctor/runChecks.js +45 -9
  12. package/dist/index.d.ts +0 -2
  13. package/dist/index.js +5 -2
  14. package/dist/registration/apiClient.d.ts +10 -33
  15. package/dist/registration/apiClient.js +10 -99
  16. package/dist/runner/startCommand.d.ts +29 -2
  17. package/dist/runner/startCommand.js +290 -77
  18. package/dist/runner/supervisor.d.ts +17 -0
  19. package/dist/runner/supervisor.js +90 -33
  20. package/dist/service/plistGenerator.js +6 -1
  21. package/dist/service/serviceInstaller.d.ts +5 -0
  22. package/dist/service/serviceInstaller.js +6 -1
  23. package/package.json +4 -3
  24. package/dist/auth/loginCommand.d.ts +0 -24
  25. package/dist/auth/loginCommand.js +0 -62
  26. package/dist/auth/logoutCommand.d.ts +0 -16
  27. package/dist/auth/logoutCommand.js +0 -22
  28. package/dist/auth/whoamiCommand.d.ts +0 -14
  29. package/dist/auth/whoamiCommand.js +0 -28
  30. package/dist/registration/index.d.ts +0 -47
  31. package/dist/registration/index.js +0 -143
  32. package/dist/registration/types.d.ts +0 -43
  33. package/dist/registration/types.js +0 -8
  34. package/dist/runner/cacheStatsReporter.d.ts +0 -46
  35. package/dist/runner/cacheStatsReporter.js +0 -92
  36. package/dist/types.d.ts +0 -48
  37. package/dist/types.js +0 -6
  38. package/dist/utils/capabilities.d.ts +0 -22
  39. package/dist/utils/capabilities.js +0 -199
  40. package/dist/utils/sdkScanner.d.ts +0 -104
  41. 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 any enrollment key exists in the keyring. */
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 a hypothetical `buildhive-agent leave` command (out of scope for v1).
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 (!token || !expStr)
89
- throw new NotLoggedInError();
90
- const exp = Number(expStr);
91
- return {
92
- jwt: token,
93
- expiresAtUnix: Number.isFinite(exp) ? exp : 0,
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 (!jwt || !expStr || !agentId || !tenantId || !platformUrl) {
109
- throw new NotLoggedInError();
110
- }
111
- const exp = Number(expStr);
112
- return {
113
- jwt,
114
- jwtExpiresAtUnix: Number.isFinite(exp) ? exp : 0,
115
- agentId,
116
- tenantId,
117
- platformUrl,
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
- const stored = await this.store.getSecret(KEY_PLATFORM_URL);
126
- if (!stored)
127
- throw new NotLoggedInError();
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 any enrollment key exists in the keyring. */
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
- return jwt !== null && jwt.length > 0;
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 a hypothetical `buildhive-agent leave` command (out of scope for v1).
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;
@@ -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
- console.log(`✓ Agent enrolled in team "${teamName}" as agent ${agentIdShort}…`);
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, or install failed. Fall back to the
210
- // foreground-start guidance the user can still execute.
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 — consumed by the agent's cacheStatsReporter.
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). */