@phnx-labs/agents-cli 1.20.21 → 1.20.23

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 (40) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/commands/cloud.js +142 -13
  3. package/dist/commands/exec.js +13 -1
  4. package/dist/commands/menubar.d.ts +10 -0
  5. package/dist/commands/menubar.js +83 -0
  6. package/dist/commands/routines.js +34 -1
  7. package/dist/commands/secrets.d.ts +1 -1
  8. package/dist/commands/secrets.js +95 -38
  9. package/dist/index.js +292 -225
  10. package/dist/lib/agents.js +8 -0
  11. package/dist/lib/cloud/antigravity.d.ts +70 -0
  12. package/dist/lib/cloud/antigravity.js +196 -0
  13. package/dist/lib/cloud/codex.d.ts +1 -0
  14. package/dist/lib/cloud/codex.js +8 -2
  15. package/dist/lib/cloud/factory.d.ts +79 -18
  16. package/dist/lib/cloud/factory.js +324 -26
  17. package/dist/lib/cloud/registry.d.ts +18 -2
  18. package/dist/lib/cloud/registry.js +28 -4
  19. package/dist/lib/cloud/types.d.ts +73 -2
  20. package/dist/lib/cloud/types.js +17 -0
  21. package/dist/lib/exec.d.ts +2 -0
  22. package/dist/lib/exec.js +5 -0
  23. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +20 -0
  24. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  25. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +115 -0
  26. package/dist/lib/menubar/install-menubar.d.ts +57 -0
  27. package/dist/lib/menubar/install-menubar.js +291 -0
  28. package/dist/lib/secrets/agent.d.ts +9 -1
  29. package/dist/lib/secrets/agent.js +91 -10
  30. package/dist/lib/secrets/bundles.d.ts +19 -12
  31. package/dist/lib/secrets/bundles.js +22 -14
  32. package/dist/lib/self-update.d.ts +34 -0
  33. package/dist/lib/self-update.js +63 -2
  34. package/dist/lib/startup/command-registry.d.ts +99 -0
  35. package/dist/lib/startup/command-registry.js +136 -0
  36. package/dist/lib/types.d.ts +8 -0
  37. package/dist/lib/version.d.ts +11 -0
  38. package/dist/lib/version.js +20 -0
  39. package/package.json +5 -3
  40. package/scripts/postinstall.js +35 -0
@@ -0,0 +1,115 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>files</key>
6
+ <dict/>
7
+ <key>files2</key>
8
+ <dict/>
9
+ <key>rules</key>
10
+ <dict>
11
+ <key>^Resources/</key>
12
+ <true/>
13
+ <key>^Resources/.*\.lproj/</key>
14
+ <dict>
15
+ <key>optional</key>
16
+ <true/>
17
+ <key>weight</key>
18
+ <real>1000</real>
19
+ </dict>
20
+ <key>^Resources/.*\.lproj/locversion.plist$</key>
21
+ <dict>
22
+ <key>omit</key>
23
+ <true/>
24
+ <key>weight</key>
25
+ <real>1100</real>
26
+ </dict>
27
+ <key>^Resources/Base\.lproj/</key>
28
+ <dict>
29
+ <key>weight</key>
30
+ <real>1010</real>
31
+ </dict>
32
+ <key>^version.plist$</key>
33
+ <true/>
34
+ </dict>
35
+ <key>rules2</key>
36
+ <dict>
37
+ <key>.*\.dSYM($|/)</key>
38
+ <dict>
39
+ <key>weight</key>
40
+ <real>11</real>
41
+ </dict>
42
+ <key>^(.*/)?\.DS_Store$</key>
43
+ <dict>
44
+ <key>omit</key>
45
+ <true/>
46
+ <key>weight</key>
47
+ <real>2000</real>
48
+ </dict>
49
+ <key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
50
+ <dict>
51
+ <key>nested</key>
52
+ <true/>
53
+ <key>weight</key>
54
+ <real>10</real>
55
+ </dict>
56
+ <key>^.*</key>
57
+ <true/>
58
+ <key>^Info\.plist$</key>
59
+ <dict>
60
+ <key>omit</key>
61
+ <true/>
62
+ <key>weight</key>
63
+ <real>20</real>
64
+ </dict>
65
+ <key>^PkgInfo$</key>
66
+ <dict>
67
+ <key>omit</key>
68
+ <true/>
69
+ <key>weight</key>
70
+ <real>20</real>
71
+ </dict>
72
+ <key>^Resources/</key>
73
+ <dict>
74
+ <key>weight</key>
75
+ <real>20</real>
76
+ </dict>
77
+ <key>^Resources/.*\.lproj/</key>
78
+ <dict>
79
+ <key>optional</key>
80
+ <true/>
81
+ <key>weight</key>
82
+ <real>1000</real>
83
+ </dict>
84
+ <key>^Resources/.*\.lproj/locversion.plist$</key>
85
+ <dict>
86
+ <key>omit</key>
87
+ <true/>
88
+ <key>weight</key>
89
+ <real>1100</real>
90
+ </dict>
91
+ <key>^Resources/Base\.lproj/</key>
92
+ <dict>
93
+ <key>weight</key>
94
+ <real>1010</real>
95
+ </dict>
96
+ <key>^[^/]+$</key>
97
+ <dict>
98
+ <key>nested</key>
99
+ <true/>
100
+ <key>weight</key>
101
+ <real>10</real>
102
+ </dict>
103
+ <key>^embedded\.provisionprofile$</key>
104
+ <dict>
105
+ <key>weight</key>
106
+ <real>20</real>
107
+ </dict>
108
+ <key>^version\.plist$</key>
109
+ <dict>
110
+ <key>weight</key>
111
+ <real>20</real>
112
+ </dict>
113
+ </dict>
114
+ </dict>
115
+ </plist>
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Install + lifecycle for the macOS menu-bar helper (`MenubarHelper.app`).
3
+ *
4
+ * Mirrors `src/lib/secrets/install-helper.ts` (stable Application Support path,
5
+ * survives npm re-sign) and the secrets-agent launchd pattern in
6
+ * `src/lib/secrets/agent.ts` (RunAtLoad + KeepAlive user service).
7
+ *
8
+ * The helper is a no-Dock `.accessory` status-bar app. It reads live agent
9
+ * state directly from disk and shells `agents` only for actions, so the plist
10
+ * bakes in the node interpreter + entry point + bin path so the GUI process can
11
+ * find the CLI without a login PATH.
12
+ *
13
+ * Opt-out is sticky: `agents menubar disable` drops a sentinel that the upgrade
14
+ * migration (`installMenubarLaunchAgent` in migrate.ts) honors, so a disabled
15
+ * menu bar never silently comes back on the next release.
16
+ */
17
+ /** True if the user explicitly disabled the menu bar (don't auto-enable on upgrade). */
18
+ export declare function menubarDisabledByUser(): boolean;
19
+ /** True if the launchd plist for the menu-bar service is installed. */
20
+ export declare function menubarServiceInstalled(): boolean;
21
+ /**
22
+ * Copy the bundled `.app` to the stable user path (idempotent unless forced).
23
+ * Returns the installed executable path, or null if no source bundle ships
24
+ * with this install (e.g. Linux package, or a build without the helper).
25
+ */
26
+ export declare function ensureMenubarAppInstalled(opts?: {
27
+ forceReinstall?: boolean;
28
+ }): string | null;
29
+ /**
30
+ * Install + start the menu-bar helper as a launchd user service (idempotent).
31
+ * Clears the sticky opt-out, installs the .app, writes the plist, and
32
+ * bootstraps it into the GUI domain. Returns false on non-darwin or when no
33
+ * helper bundle ships with this install.
34
+ */
35
+ export declare function enableMenubarService(opts?: {
36
+ clearOptOut?: boolean;
37
+ }): boolean;
38
+ /**
39
+ * Stop + remove the menu-bar service and write the sticky opt-out so the
40
+ * upgrade migration won't re-enable it.
41
+ */
42
+ export declare function disableMenubarService(): void;
43
+ /**
44
+ * Upgrade-time auto-enable. Runs from runMigration() once per sentinel bump.
45
+ * No-ops if: not darwin, the user opted out, no helper bundle ships, or the
46
+ * service is already installed. Best-effort — never throws into migration.
47
+ */
48
+ export declare function installMenubarLaunchAgentOnUpgrade(): void;
49
+ export interface MenubarStatus {
50
+ platform: string;
51
+ source: string | null;
52
+ installedApp: string | null;
53
+ serviceInstalled: boolean;
54
+ running: boolean;
55
+ disabledByUser: boolean;
56
+ }
57
+ export declare function getMenubarStatus(): MenubarStatus;
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Install + lifecycle for the macOS menu-bar helper (`MenubarHelper.app`).
3
+ *
4
+ * Mirrors `src/lib/secrets/install-helper.ts` (stable Application Support path,
5
+ * survives npm re-sign) and the secrets-agent launchd pattern in
6
+ * `src/lib/secrets/agent.ts` (RunAtLoad + KeepAlive user service).
7
+ *
8
+ * The helper is a no-Dock `.accessory` status-bar app. It reads live agent
9
+ * state directly from disk and shells `agents` only for actions, so the plist
10
+ * bakes in the node interpreter + entry point + bin path so the GUI process can
11
+ * find the CLI without a login PATH.
12
+ *
13
+ * Opt-out is sticky: `agents menubar disable` drops a sentinel that the upgrade
14
+ * migration (`installMenubarLaunchAgent` in migrate.ts) honors, so a disabled
15
+ * menu bar never silently comes back on the next release.
16
+ */
17
+ import { fileURLToPath } from 'url';
18
+ import { execFileSync, spawnSync } from 'child_process';
19
+ import * as fs from 'fs';
20
+ import * as os from 'os';
21
+ import * as path from 'path';
22
+ import { getRuntimeStateDir, getHelpersDir } from '../state.js';
23
+ const APP_BUNDLE_NAME = 'MenubarHelper.app';
24
+ const INSTALL_DIR_NAME = 'agents-cli';
25
+ const SERVICE_LABEL = 'com.phnx-labs.agents-menubar';
26
+ function onDarwin() {
27
+ return process.platform === 'darwin';
28
+ }
29
+ /** ~/Library/Application Support/agents-cli/MenubarHelper.app */
30
+ function installedAppPath() {
31
+ return path.join(os.homedir(), 'Library', 'Application Support', INSTALL_DIR_NAME, APP_BUNDLE_NAME);
32
+ }
33
+ /** Executable inside the installed bundle. */
34
+ function installedExecutablePath() {
35
+ return path.join(installedAppPath(), 'Contents', 'MacOS', 'MenubarHelper');
36
+ }
37
+ /** ~/Library/LaunchAgents/com.phnx-labs.agents-menubar.plist */
38
+ function servicePlistPath() {
39
+ return path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
40
+ }
41
+ /** Sticky opt-out marker written by `agents menubar disable`. */
42
+ function disabledSentinelPath() {
43
+ return path.join(getRuntimeStateDir(), 'menubar.disabled');
44
+ }
45
+ /** True if the user explicitly disabled the menu bar (don't auto-enable on upgrade). */
46
+ export function menubarDisabledByUser() {
47
+ return fs.existsSync(disabledSentinelPath());
48
+ }
49
+ /** True if the launchd plist for the menu-bar service is installed. */
50
+ export function menubarServiceInstalled() {
51
+ return onDarwin() && fs.existsSync(servicePlistPath());
52
+ }
53
+ /**
54
+ * Locate the source `.app` shipped alongside the compiled JS.
55
+ * 1. dist/lib/menubar/MenubarHelper.app — npm install layout (sibling of this file)
56
+ * 2. <repo>/bin/MenubarHelper.app — raw working tree (tsx/dev)
57
+ * 3. <repo>/packages/menubar-helper/dist/MenubarHelper.app — fresh local build
58
+ */
59
+ function sourceAppPath() {
60
+ const candidates = [];
61
+ try {
62
+ const here = path.dirname(fileURLToPath(import.meta.url));
63
+ candidates.push(path.join(here, APP_BUNDLE_NAME));
64
+ candidates.push(path.resolve(here, '..', '..', '..', 'bin', APP_BUNDLE_NAME));
65
+ candidates.push(path.resolve(here, '..', '..', '..', 'packages', 'menubar-helper', 'dist', APP_BUNDLE_NAME));
66
+ }
67
+ catch {
68
+ /* import.meta.url unavailable */
69
+ }
70
+ for (const c of candidates) {
71
+ if (fs.existsSync(c))
72
+ return c;
73
+ }
74
+ return null;
75
+ }
76
+ /** Resolve the `agents` launcher binary on PATH-less GUI processes. */
77
+ function resolveAgentsBin() {
78
+ const home = os.homedir();
79
+ const candidates = [
80
+ path.join(home, '.local', 'bin', 'agents'),
81
+ '/opt/homebrew/bin/agents',
82
+ '/usr/local/bin/agents',
83
+ path.join(home, '.npm-global', 'bin', 'agents'),
84
+ ];
85
+ for (const c of candidates) {
86
+ try {
87
+ fs.accessSync(c, fs.constants.X_OK);
88
+ return c;
89
+ }
90
+ catch {
91
+ /* try next */
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+ /** Resolve the compiled CLI entry (dist/index.js) so the helper can exec node directly. */
97
+ function resolveCliEntry() {
98
+ try {
99
+ const here = path.dirname(fileURLToPath(import.meta.url));
100
+ // dist/lib/menubar/install-menubar.js -> dist/index.js
101
+ const entry = path.resolve(here, '..', '..', 'index.js');
102
+ if (fs.existsSync(entry))
103
+ return entry;
104
+ }
105
+ catch {
106
+ /* ignore */
107
+ }
108
+ return null;
109
+ }
110
+ function copyAppBundle(src, dest) {
111
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
112
+ if (fs.existsSync(dest))
113
+ fs.rmSync(dest, { recursive: true, force: true });
114
+ // `cp -R` preserves the bundle's signature and resource forks (see install-helper.ts).
115
+ const r = spawnSync('cp', ['-R', src, dest], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
116
+ if (r.status !== 0) {
117
+ const msg = (r.stderr || r.stdout || '').toString().trim();
118
+ throw new Error(`Failed to copy ${src} -> ${dest}: ${msg || 'unknown error'}`);
119
+ }
120
+ }
121
+ /**
122
+ * Copy the bundled `.app` to the stable user path (idempotent unless forced).
123
+ * Returns the installed executable path, or null if no source bundle ships
124
+ * with this install (e.g. Linux package, or a build without the helper).
125
+ */
126
+ export function ensureMenubarAppInstalled(opts = {}) {
127
+ if (!onDarwin())
128
+ return null;
129
+ const src = sourceAppPath();
130
+ if (!src)
131
+ return null;
132
+ const dest = installedAppPath();
133
+ if (!opts.forceReinstall && fs.existsSync(dest))
134
+ return installedExecutablePath();
135
+ copyAppBundle(src, dest);
136
+ return installedExecutablePath();
137
+ }
138
+ function xmlEscape(s) {
139
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
140
+ }
141
+ function generateServicePlist(execPath) {
142
+ const home = os.homedir();
143
+ const logPath = path.join(getHelpersDir(), 'menubar', 'menubar.log');
144
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
145
+ // Bake interpreter + entry + bin so the GUI helper can reach the CLI with no
146
+ // login PATH. AgentsCLI.swift prefers [AGENTS_NODE, AGENTS_ENTRY] when both
147
+ // exist, else falls back to AGENTS_BIN, else probes well-known paths.
148
+ const env = {
149
+ PATH: `/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${path.dirname(process.execPath)}:${home}/.local/bin`,
150
+ };
151
+ const node = process.execPath;
152
+ const entry = resolveCliEntry();
153
+ const bin = resolveAgentsBin();
154
+ if (node && entry) {
155
+ env.AGENTS_NODE = node;
156
+ env.AGENTS_ENTRY = entry;
157
+ }
158
+ if (bin)
159
+ env.AGENTS_BIN = bin;
160
+ const envXml = Object.entries(env)
161
+ .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
162
+ .join('\n');
163
+ return `<?xml version="1.0" encoding="UTF-8"?>
164
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
165
+ <plist version="1.0">
166
+ <dict>
167
+ <key>Label</key>
168
+ <string>${SERVICE_LABEL}</string>
169
+ <key>ProgramArguments</key>
170
+ <array>
171
+ <string>${xmlEscape(execPath)}</string>
172
+ </array>
173
+ <key>RunAtLoad</key>
174
+ <true/>
175
+ <key>KeepAlive</key>
176
+ <true/>
177
+ <key>ProcessType</key>
178
+ <string>Interactive</string>
179
+ <key>StandardOutPath</key>
180
+ <string>${xmlEscape(logPath)}</string>
181
+ <key>StandardErrorPath</key>
182
+ <string>${xmlEscape(logPath)}</string>
183
+ <key>EnvironmentVariables</key>
184
+ <dict>
185
+ ${envXml}
186
+ </dict>
187
+ </dict>
188
+ </plist>`;
189
+ }
190
+ /**
191
+ * Install + start the menu-bar helper as a launchd user service (idempotent).
192
+ * Clears the sticky opt-out, installs the .app, writes the plist, and
193
+ * bootstraps it into the GUI domain. Returns false on non-darwin or when no
194
+ * helper bundle ships with this install.
195
+ */
196
+ export function enableMenubarService(opts = { clearOptOut: true }) {
197
+ if (!onDarwin())
198
+ return false;
199
+ const exec = ensureMenubarAppInstalled({ forceReinstall: true });
200
+ if (!exec)
201
+ return false;
202
+ if (opts.clearOptOut) {
203
+ try {
204
+ fs.rmSync(disabledSentinelPath(), { force: true });
205
+ }
206
+ catch { /* already gone */ }
207
+ }
208
+ const plist = servicePlistPath();
209
+ fs.mkdirSync(path.dirname(plist), { recursive: true });
210
+ fs.writeFileSync(plist, generateServicePlist(exec));
211
+ const uid = process.getuid?.() ?? 0;
212
+ try {
213
+ execFileSync('launchctl', ['bootstrap', `gui/${uid}`, plist], { stdio: ['ignore', 'ignore', 'ignore'] });
214
+ }
215
+ catch {
216
+ try {
217
+ execFileSync('launchctl', ['load', '-w', plist], { stdio: ['ignore', 'ignore', 'ignore'] });
218
+ }
219
+ catch { /* may already be loaded */ }
220
+ }
221
+ try {
222
+ execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
223
+ }
224
+ catch { /* best effort */ }
225
+ return true;
226
+ }
227
+ /**
228
+ * Stop + remove the menu-bar service and write the sticky opt-out so the
229
+ * upgrade migration won't re-enable it.
230
+ */
231
+ export function disableMenubarService() {
232
+ if (!onDarwin())
233
+ return;
234
+ const plist = servicePlistPath();
235
+ const uid = process.getuid?.() ?? 0;
236
+ try {
237
+ execFileSync('launchctl', ['bootout', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
238
+ }
239
+ catch {
240
+ try {
241
+ execFileSync('launchctl', ['unload', '-w', plist], { stdio: ['ignore', 'ignore', 'ignore'] });
242
+ }
243
+ catch { /* not loaded */ }
244
+ }
245
+ try {
246
+ fs.unlinkSync(plist);
247
+ }
248
+ catch { /* already gone */ }
249
+ try {
250
+ fs.mkdirSync(path.dirname(disabledSentinelPath()), { recursive: true });
251
+ fs.writeFileSync(disabledSentinelPath(), `disabled ${new Date().toISOString()}\n`);
252
+ }
253
+ catch { /* best effort */ }
254
+ }
255
+ /**
256
+ * Upgrade-time auto-enable. Runs from runMigration() once per sentinel bump.
257
+ * No-ops if: not darwin, the user opted out, no helper bundle ships, or the
258
+ * service is already installed. Best-effort — never throws into migration.
259
+ */
260
+ export function installMenubarLaunchAgentOnUpgrade() {
261
+ try {
262
+ if (!onDarwin())
263
+ return;
264
+ if (menubarDisabledByUser())
265
+ return;
266
+ if (menubarServiceInstalled())
267
+ return;
268
+ if (!sourceAppPath())
269
+ return;
270
+ enableMenubarService({ clearOptOut: false });
271
+ }
272
+ catch {
273
+ /* never block migration on the menu bar */
274
+ }
275
+ }
276
+ export function getMenubarStatus() {
277
+ const dest = installedAppPath();
278
+ let running = false;
279
+ if (onDarwin()) {
280
+ const r = spawnSync('pgrep', ['-f', 'MenubarHelper'], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
281
+ running = r.status === 0 && (r.stdout || '').trim().length > 0;
282
+ }
283
+ return {
284
+ platform: process.platform,
285
+ source: sourceAppPath(),
286
+ installedApp: fs.existsSync(dest) ? dest : null,
287
+ serviceInstalled: menubarServiceInstalled(),
288
+ running,
289
+ disabledByUser: menubarDisabledByUser(),
290
+ };
291
+ }
@@ -46,6 +46,13 @@ export declare function secretsAgentServiceInstalled(): boolean;
46
46
  * so it can boot even when the machine is loaded. Returns true once reachable.
47
47
  */
48
48
  export declare function installSecretsAgentService(timeoutMs?: number): Promise<boolean>;
49
+ /**
50
+ * Kickstart the already-installed persistent broker so launchd relaunches it
51
+ * onto the current on-disk code. Used by postinstall heal-on-upgrade. No-op if
52
+ * the service isn't installed; never rewrites the plist or waits, so it's safe
53
+ * and fast to call from an installer.
54
+ */
55
+ export declare function kickstartSecretsAgentService(): void;
49
56
  /** Stop + remove the persistent broker service, and wipe whatever it held. */
50
57
  export declare function uninstallSecretsAgentService(): Promise<void>;
51
58
  export type Request = {
@@ -69,6 +76,7 @@ export type Response = {
69
76
  ok: true;
70
77
  cmd: 'ping';
71
78
  version: number;
79
+ cliVersion: string;
72
80
  } | {
73
81
  ok: true;
74
82
  cmd: 'get';
@@ -127,7 +135,7 @@ export declare function secretsAgentAutoEnabled(): boolean;
127
135
  /**
128
136
  * Fire-and-forget: populate the broker with a freshly-resolved bundle so the
129
137
  * NEXT process reads it without a prompt. Used by the auto-cache path after a
130
- * real keychain read of a `session`-tier bundle. Adds no latency to the caller
138
+ * real keychain read of a `daily`-policy bundle. Adds no latency to the caller
131
139
  * — it spawns a detached `secrets _agent-load` worker (passing the resolved env
132
140
  * over stdin, never argv) and returns immediately.
133
141
  *
@@ -30,6 +30,7 @@ import { spawn, spawnSync, execFileSync } from 'child_process';
30
30
  import { getHelpersDir, readMeta } from '../state.js';
31
31
  import { isAlive } from '../platform/process.js';
32
32
  import { getKeychainHelperPath } from './install-helper.js';
33
+ import { getCliVersion, getCliVersionFresh } from '../version.js';
33
34
  /** Bumped when the wire protocol changes; a client that pings a mismatched
34
35
  * server kills and respawns it rather than talking a stale dialect. */
35
36
  const PROTOCOL_VERSION = 1;
@@ -164,12 +165,27 @@ export async function installSecretsAgentService(timeoutMs = 30000) {
164
165
  catch { /* best effort */ }
165
166
  const deadline = Date.now() + timeoutMs;
166
167
  while (Date.now() < deadline) {
167
- if (await agentPing())
168
+ if ((await agentPing()).reachable)
168
169
  return true;
169
170
  await new Promise((r) => setTimeout(r, 200));
170
171
  }
171
172
  return false;
172
173
  }
174
+ /**
175
+ * Kickstart the already-installed persistent broker so launchd relaunches it
176
+ * onto the current on-disk code. Used by postinstall heal-on-upgrade. No-op if
177
+ * the service isn't installed; never rewrites the plist or waits, so it's safe
178
+ * and fast to call from an installer.
179
+ */
180
+ export function kickstartSecretsAgentService() {
181
+ if (!onDarwin() || !secretsAgentServiceInstalled())
182
+ return;
183
+ const uid = process.getuid?.() ?? 0;
184
+ try {
185
+ execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
186
+ }
187
+ catch { /* best effort */ }
188
+ }
173
189
  /** Stop + remove the persistent broker service, and wipe whatever it held. */
174
190
  export async function uninstallSecretsAgentService() {
175
191
  if (!onDarwin())
@@ -201,7 +217,11 @@ export async function uninstallSecretsAgentService() {
201
217
  export function handleAgentRequest(store, req, now = Date.now()) {
202
218
  switch (req.cmd) {
203
219
  case 'ping':
204
- return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION };
220
+ // Report the version of the code this broker is RUNNING (getCliVersion
221
+ // caches the value from the broker's startup), not the on-disk version.
222
+ // A client compares this to its own fresh on-disk read; a mismatch means
223
+ // the broker is running pre-upgrade code and should be restarted.
224
+ return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION, cliVersion: getCliVersion() };
205
225
  case 'get': {
206
226
  const e = store.get(req.name);
207
227
  if (!e || now >= e.expiresAt) {
@@ -278,11 +298,25 @@ export async function runSecretsAgent(opts = {}) {
278
298
  fs.unlinkSync(sock);
279
299
  }
280
300
  catch { /* no stale socket */ }
301
+ // Capture the version of the code we're running so the sweep can detect when
302
+ // an in-place upgrade has landed and self-heal onto it. getCliVersion caches
303
+ // this value for the process lifetime; getCliVersionFresh re-reads on disk.
304
+ const runningVersion = getCliVersion();
281
305
  const sweep = () => {
282
306
  const now = Date.now();
283
307
  for (const [name, e] of store)
284
308
  if (now >= e.expiresAt)
285
309
  store.delete(name);
310
+ // Self-heal: a newer version was installed in place — exit so launchd
311
+ // relaunches us on the new code. Only meaningful when launchd will restart
312
+ // us (persistent); a one-off broker just keeps serving until idle.
313
+ if (persistent) {
314
+ const onDisk = getCliVersionFresh();
315
+ if (onDisk !== 'unknown' && runningVersion !== 'unknown' && onDisk !== runningVersion) {
316
+ shutdown(0); // KeepAlive relaunches on the new code
317
+ return;
318
+ }
319
+ }
286
320
  if (store.size === 0) {
287
321
  if (!persistent && now - emptySince >= IDLE_EXIT_MS)
288
322
  shutdown(0);
@@ -489,7 +523,7 @@ export function secretsAgentAutoEnabled() {
489
523
  /**
490
524
  * Fire-and-forget: populate the broker with a freshly-resolved bundle so the
491
525
  * NEXT process reads it without a prompt. Used by the auto-cache path after a
492
- * real keychain read of a `session`-tier bundle. Adds no latency to the caller
526
+ * real keychain read of a `daily`-policy bundle. Adds no latency to the caller
493
527
  * — it spawns a detached `secrets _agent-load` worker (passing the resolved env
494
528
  * over stdin, never argv) and returns immediately.
495
529
  *
@@ -556,12 +590,16 @@ export async function agentStatus() {
556
590
  const r = await request({ cmd: 'status' });
557
591
  return r?.ok === true && r.cmd === 'status' ? r.entries : [];
558
592
  }
559
- /** Is a broker live and speaking our protocol version? */
593
+ /** Ping result: whether a broker is reachable + speaking our protocol, and the
594
+ * version of the code it's running (for staleness detection). */
560
595
  async function agentPing() {
561
596
  if (!agentSocketExists())
562
- return false;
597
+ return { reachable: false };
563
598
  const r = await request({ cmd: 'ping' });
564
- return r?.ok === true && r.cmd === 'ping' && r.version === PROTOCOL_VERSION;
599
+ if (r?.ok === true && r.cmd === 'ping' && r.version === PROTOCOL_VERSION) {
600
+ return { reachable: true, cliVersion: r.cliVersion };
601
+ }
602
+ return { reachable: false };
565
603
  }
566
604
  /**
567
605
  * Ensure a broker is running and reachable. Returns true once the socket answers
@@ -576,8 +614,16 @@ async function agentPing() {
576
614
  export async function ensureAgentRunning(timeoutMs = 5000) {
577
615
  if (!onDarwin())
578
616
  return false;
579
- if (await agentPing())
580
- return true;
617
+ // Self-heal: if a broker is reachable but running pre-upgrade code (its
618
+ // reported version != the version on disk now), tear it down so the paths
619
+ // below bring up a fresh one on current code. A current, reachable broker is
620
+ // accepted immediately.
621
+ const ping = await agentPing();
622
+ if (ping.reachable) {
623
+ if (ping.cliVersion === undefined || ping.cliVersion === getCliVersionFresh())
624
+ return true;
625
+ await teardownStaleBroker();
626
+ }
581
627
  // Path 1: the persistent service. installSecretsAgentService is idempotent and
582
628
  // waits for the socket; for an already-installed service we kickstart and wait.
583
629
  try {
@@ -593,7 +639,7 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
593
639
  catch { /* may already be running */ }
594
640
  const d = Date.now() + timeoutMs;
595
641
  while (Date.now() < d) {
596
- if (await agentPing())
642
+ if ((await agentPing()).reachable)
597
643
  return true;
598
644
  await new Promise((r) => setTimeout(r, 150));
599
645
  }
@@ -627,9 +673,44 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
627
673
  spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
628
674
  const deadline = Date.now() + timeoutMs;
629
675
  while (Date.now() < deadline) {
630
- if (await agentPing())
676
+ if ((await agentPing()).reachable)
631
677
  return true;
632
678
  await new Promise((r) => setTimeout(r, 100));
633
679
  }
634
680
  return false;
635
681
  }
682
+ /**
683
+ * Tear down a stale broker (running pre-upgrade code) so a fresh one can take
684
+ * over. If the persistent service is installed, bootout makes launchd relaunch
685
+ * it on the new code; otherwise kill the process and clear its socket/pid.
686
+ */
687
+ async function teardownStaleBroker() {
688
+ if (secretsAgentServiceInstalled()) {
689
+ const uid = process.getuid?.() ?? 0;
690
+ try {
691
+ execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
692
+ }
693
+ catch { /* best effort */ }
694
+ return; // kickstart -k restarts the service onto current code; socket/pid managed by it
695
+ }
696
+ const pid = (() => { try {
697
+ return parseInt(fs.readFileSync(pidPath(), 'utf-8').trim(), 10);
698
+ }
699
+ catch {
700
+ return NaN;
701
+ } })();
702
+ if (!isNaN(pid) && isAlive(pid)) {
703
+ try {
704
+ process.kill(pid, 'SIGTERM');
705
+ }
706
+ catch { /* gone */ }
707
+ }
708
+ try {
709
+ fs.unlinkSync(socketPath());
710
+ }
711
+ catch { /* gone */ }
712
+ try {
713
+ fs.unlinkSync(pidPath());
714
+ }
715
+ catch { /* gone */ }
716
+ }