@phnx-labs/agents-cli 1.20.20 → 1.20.22

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.
@@ -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
+ }
@@ -39,15 +39,21 @@ export interface VarMeta {
39
39
  note?: string;
40
40
  }
41
41
  /**
42
- * How a bundle interacts with the macOS secrets-agent:
43
- * - `biometry` (default): only an explicit `agents secrets unlock` populates the
44
- * agent; every other read pops Touch ID. Use for high-value bundles you want
45
- * to confirm each session.
46
- * - `session`: eligible for the agent `unlock`, and (when `secrets.agent.auto`
47
- * is enabled) the first real keychain read auto-loads it so concurrent runs
48
- * read it silently.
42
+ * A bundle's prompt policy how often macOS asks for Touch ID to read it:
43
+ * - `always` (default): asks every time. Only an explicit `agents secrets
44
+ * unlock` ever holds it in the secrets-agent; every other read pops Touch ID.
45
+ * Use for high-value bundles you want to confirm every time.
46
+ * - `daily`: ask once, then hold it silently. Eligible for the secrets-agent
47
+ * `unlock` it, or (when `secrets.agent.auto` is enabled) the first real
48
+ * keychain read auto-loads it so concurrent runs read it silently. Held up to
49
+ * ~24h from that unlock (not refreshed on use); re-asks sooner after
50
+ * screen-lock, sleep, logout, or `agents secrets lock`.
51
+ *
52
+ * Stored on disk under the legacy `tier` key (`session` == `daily`; absent ==
53
+ * `always`) so bundles stay readable across mixed CLI versions on synced
54
+ * machines. The in-memory and user-facing vocabulary is `policy`/`always`/`daily`.
49
55
  */
50
- export type SecretsTier = 'biometry' | 'session';
56
+ export type SecretsPolicy = 'always' | 'daily';
51
57
  /** A named set of environment variable definitions backed by various secret providers. */
52
58
  export interface SecretsBundle {
53
59
  name: string;
@@ -55,8 +61,9 @@ export interface SecretsBundle {
55
61
  allow_exec?: boolean;
56
62
  /** Which store carries this bundle's items. Absent ⇒ `keychain` (the default). */
57
63
  backend?: SecretsBackend;
58
- /** Secrets-agent interaction tier. Absent ⇒ `biometry` (the safe default). */
59
- tier?: SecretsTier;
64
+ /** Prompt policy. Absent ⇒ `always` (the safe default). Serialized under the
65
+ * legacy `tier` key — see SecretsPolicy. */
66
+ policy?: SecretsPolicy;
60
67
  /** ISO 8601 UTC timestamp. Set once on the first writeBundle() for a bundle. */
61
68
  created_at?: string;
62
69
  /** ISO 8601 UTC timestamp. Refreshed on every writeBundle(). */
@@ -90,8 +97,8 @@ export declare function validateSecretType(t: string): asserts t is SecretType;
90
97
  export declare function validateExpiresFutureDated(iso: string): void;
91
98
  export declare function bundleExists(name: string): boolean;
92
99
  export declare function readBundle(name: string): SecretsBundle;
93
- /** The effective tier of a bundle (absent ⇒ `biometry`). */
94
- export declare function bundleTier(bundle: SecretsBundle): SecretsTier;
100
+ /** The effective prompt policy of a bundle (absent ⇒ `always`). */
101
+ export declare function bundlePolicy(bundle: SecretsBundle): SecretsPolicy;
95
102
  export declare function writeBundle(bundle: SecretsBundle): void;
96
103
  export declare function deleteBundle(name: string): boolean;
97
104
  export declare function listBundles(): SecretsBundle[];
@@ -223,10 +223,11 @@ export function readBundle(name) {
223
223
  name,
224
224
  description: parsed.description,
225
225
  allow_exec: Boolean(parsed.allow_exec),
226
- // Absent ⇒ keychain (mirrors `tier`); only set when file-backed so a
227
- // keychain bundle round-trips byte-for-byte.
226
+ // Absent ⇒ keychain; only set when file-backed so a keychain bundle
227
+ // round-trips byte-for-byte.
228
228
  backend: backend === 'file' ? 'file' : undefined,
229
- tier: parseTier(parsed.tier),
229
+ // Legacy wire key: the policy is persisted under `tier` (`session` == `daily`).
230
+ policy: parsePolicy(parsed.tier),
230
231
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
231
232
  };
232
233
  if (typeof parsed.created_at === 'string')
@@ -243,13 +244,16 @@ export function readBundle(name) {
243
244
  }
244
245
  return bundle;
245
246
  }
246
- /** Normalize a persisted `tier` value; anything but `session` default tier. */
247
- function parseTier(raw) {
248
- return raw === 'session' ? 'session' : undefined;
247
+ /** Normalize the persisted prompt policy. The on-disk `tier` key uses the
248
+ * legacy `session` token for `daily` (and `biometry`/absent for the default),
249
+ * so accept both the legacy and current tokens. Anything but `daily`/`session`
250
+ * ⇒ undefined (resolves to the `always` default). */
251
+ function parsePolicy(raw) {
252
+ return raw === 'daily' || raw === 'session' ? 'daily' : undefined;
249
253
  }
250
- /** The effective tier of a bundle (absent ⇒ `biometry`). */
251
- export function bundleTier(bundle) {
252
- return bundle.tier ?? 'biometry';
254
+ /** The effective prompt policy of a bundle (absent ⇒ `always`). */
255
+ export function bundlePolicy(bundle) {
256
+ return bundle.policy ?? 'always';
253
257
  }
254
258
  export function writeBundle(bundle) {
255
259
  validateBundleName(bundle.name);
@@ -288,7 +292,9 @@ export function writeBundle(bundle) {
288
292
  description: bundle.description,
289
293
  allow_exec: bundle.allow_exec ? true : undefined,
290
294
  backend: backend === 'file' ? 'file' : undefined,
291
- tier: bundle.tier === 'session' ? 'session' : undefined,
295
+ // Wire format: persist `daily` under the legacy `tier`/`session` token so
296
+ // older CLI versions on other synced machines keep reading the policy.
297
+ tier: bundle.policy === 'daily' ? 'session' : undefined,
292
298
  created_at: bundle.created_at,
293
299
  updated_at: bundle.updated_at,
294
300
  last_used: bundle.last_used,
@@ -328,7 +334,8 @@ function parseBundleMeta(name, json, backend) {
328
334
  description: parsed.description,
329
335
  allow_exec: Boolean(parsed.allow_exec),
330
336
  backend: backend === 'file' ? 'file' : undefined,
331
- tier: parseTier(parsed.tier),
337
+ // Legacy wire key: the policy is persisted under `tier` (`session` == `daily`).
338
+ policy: parsePolicy(parsed.tier),
332
339
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
333
340
  };
334
341
  if (typeof parsed.created_at === 'string')
@@ -568,7 +575,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
568
575
  description: parsed.description,
569
576
  allow_exec: Boolean(parsed.allow_exec),
570
577
  backend: backend === 'file' ? 'file' : undefined,
571
- tier: parseTier(parsed.tier),
578
+ // Legacy wire key: the policy is persisted under `tier` (`session` == `daily`).
579
+ policy: parsePolicy(parsed.tier),
572
580
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
573
581
  };
574
582
  if (typeof parsed.created_at === 'string')
@@ -639,14 +647,14 @@ export function readAndResolveBundleEnv(name, opts = {}) {
639
647
  }
640
648
  emitReadAudit('success');
641
649
  // Auto-cache: this was a real keychain read (the agent fast-path returned
642
- // earlier on a hit). If the bundle opts into the session tier and the user
650
+ // earlier on a hit). If the bundle opts into the `daily` policy and the user
643
651
  // enabled `secrets.agent.auto`, populate the broker in the background so the
644
652
  // next concurrent run reads silently. Skipped when noAgent (e.g. `unlock`,
645
653
  // which loads the agent itself). Fire-and-forget — never blocks this read.
646
654
  if (backend === 'keychain' &&
647
655
  !opts.noAgent &&
648
656
  process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
649
- bundleTier(bundle) === 'session' &&
657
+ bundlePolicy(bundle) === 'daily' &&
650
658
  secretsAgentAutoEnabled()) {
651
659
  agentAutoLoadSync(name, bundle, env, DEFAULT_TTL_MS);
652
660
  }