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.
@@ -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
+ }
@@ -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.