@phnx-labs/agents-cli 1.22.0 → 1.22.3

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 (42) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/cloud.js +1 -1
  5. package/dist/commands/doctor.js +34 -2
  6. package/dist/commands/exec.js +1 -1
  7. package/dist/commands/projects.js +128 -66
  8. package/dist/commands/secrets.js +9 -5
  9. package/dist/commands/teams.js +2 -2
  10. package/dist/commands/watchdog.js +64 -5
  11. package/dist/lib/app-bundle-install.d.ts +17 -0
  12. package/dist/lib/app-bundle-install.js +94 -0
  13. package/dist/lib/devices/doctor-findings.js +12 -4
  14. package/dist/lib/devices/doctor-overview-cache.d.ts +45 -0
  15. package/dist/lib/devices/doctor-overview-cache.js +168 -0
  16. package/dist/lib/devices/fleet.js +7 -2
  17. package/dist/lib/devices/self-host.d.ts +9 -0
  18. package/dist/lib/devices/self-host.js +61 -0
  19. package/dist/lib/hosts/passthrough.js +8 -6
  20. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  21. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  22. package/dist/lib/menubar/install-menubar.js +27 -23
  23. package/dist/lib/project-status.d.ts +7 -0
  24. package/dist/lib/project-status.js +9 -0
  25. package/dist/lib/projects.d.ts +26 -2
  26. package/dist/lib/projects.js +69 -9
  27. package/dist/lib/rotate.d.ts +16 -1
  28. package/dist/lib/rotate.js +33 -7
  29. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  30. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  31. package/dist/lib/secrets/bundles.d.ts +20 -0
  32. package/dist/lib/secrets/bundles.js +50 -0
  33. package/dist/lib/secrets/install-helper.js +28 -31
  34. package/dist/lib/share/config.d.ts +14 -6
  35. package/dist/lib/share/config.js +23 -5
  36. package/dist/lib/types.d.ts +10 -0
  37. package/dist/lib/versions.js +69 -22
  38. package/dist/lib/watchdog/rotate.d.ts +218 -0
  39. package/dist/lib/watchdog/rotate.js +378 -0
  40. package/dist/lib/watchdog/runner.d.ts +33 -1
  41. package/dist/lib/watchdog/runner.js +303 -0
  42. package/package.json +1 -1
@@ -22,6 +22,7 @@ import * as path from 'path';
22
22
  import { sleepSync } from '../fs-atomic.js';
23
23
  import { getRuntimeStateDir, getHelpersDir } from '../state.js';
24
24
  import { getCliVersion, resolveAgentsBin, resolveInstalledLayout } from '../version.js';
25
+ import { copyAppBundle, withInstallLock } from '../app-bundle-install.js';
25
26
  const APP_BUNDLE_NAME = 'MenubarHelper.app';
26
27
  const INSTALL_DIR_NAME = 'agents-cli';
27
28
  const SERVICE_LABEL = 'com.phnx-labs.agents-menubar';
@@ -154,17 +155,6 @@ function resolveCliEntry() {
154
155
  }
155
156
  return null;
156
157
  }
157
- function copyAppBundle(src, dest) {
158
- fs.mkdirSync(path.dirname(dest), { recursive: true });
159
- if (fs.existsSync(dest))
160
- fs.rmSync(dest, { recursive: true, force: true });
161
- // `cp -R` preserves the bundle's signature and resource forks (see install-helper.ts).
162
- const r = spawnSync('cp', ['-R', src, dest], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
163
- if (r.status !== 0) {
164
- const msg = (r.stderr || r.stdout || '').toString().trim();
165
- throw new Error(`Failed to copy ${src} -> ${dest}: ${msg || 'unknown error'}`);
166
- }
167
- }
168
158
  /**
169
159
  * Register the freshly-installed bundle with LaunchServices.
170
160
  *
@@ -232,18 +222,32 @@ export function ensureMenubarAppInstalled(opts = {}) {
232
222
  if (!src)
233
223
  return null;
234
224
  const dest = installedAppPath();
235
- if (!opts.forceReinstall && fs.existsSync(dest)) {
236
- const sourceIsDevId = hasDeveloperIdSignature(src);
237
- const destIsDevId = hasDeveloperIdSignature(dest);
238
- if (!(sourceIsDevId && !destIsDevId)) {
239
- return installedExecutablePath();
240
- }
241
- }
242
- copyAppBundle(src, dest);
243
- // A fresh copy is exactly when the bundle's icon can be new (first install) or
244
- // superseded (upgrade) — register it so LaunchServices knows the bundle and can
245
- // resolve its AppIcon for the left-hand slot of daemon notifications.
246
- refreshBundleIconRegistration(dest);
225
+ // Heal an older install that was ad-hoc re-signed over a Developer ID source:
226
+ // that unstable identity made Accessibility re-prompt on every upgrade.
227
+ const needsInstall = () => {
228
+ if (opts.forceReinstall)
229
+ return true;
230
+ if (!fs.existsSync(dest))
231
+ return true;
232
+ return hasDeveloperIdSignature(src) && !hasDeveloperIdSignature(dest);
233
+ };
234
+ // Fast path: nothing to do.
235
+ if (!needsInstall())
236
+ return installedExecutablePath();
237
+ // Serialize the atomic install so concurrent `agents` invocations (this runs
238
+ // on the darwin startup path) don't race the swap or each re-copy — the
239
+ // stampede that transiently corrupted MenubarHelper.app and tripped the
240
+ // "damaged" dialog.
241
+ withInstallLock(dest, (heartbeat) => {
242
+ if (!needsInstall())
243
+ return;
244
+ copyAppBundle(src, dest);
245
+ heartbeat(); // cp -R done; keep the lock fresh across lsregister
246
+ // A fresh copy is exactly when the bundle's icon can be new (first install) or
247
+ // superseded (upgrade) — register it so LaunchServices knows the bundle and can
248
+ // resolve its AppIcon for the left-hand slot of daemon notifications.
249
+ refreshBundleIconRegistration(dest);
250
+ });
247
251
  return installedExecutablePath();
248
252
  }
249
253
  function xmlEscape(s) {
@@ -54,6 +54,13 @@ export interface ProjectSessionRollup {
54
54
  * with the full definition list to show zero-agent projects.
55
55
  */
56
56
  export declare function rollupSessionsByProject(defs: ProjectDef[], sessions: ActiveSession[]): Map<string, ProjectSessionRollup>;
57
+ /**
58
+ * True when a session's status means it is over. Exported so the card can keep
59
+ * the `agents` roster to live sessions: the headline and the `dead` row already
60
+ * separate the two, and a roster that reads `crashed ×25` beside `23 live`
61
+ * makes the reader distrust both numbers.
62
+ */
63
+ export declare function isDeadStatus(status: string): boolean;
57
64
  /** Live vs finished sessions on a project. */
58
65
  export interface LiveDeadSplit {
59
66
  live: number;
@@ -93,6 +93,15 @@ export function rollupSessionsByProject(defs, sessions) {
93
93
  * that are running unattended.
94
94
  */
95
95
  const DEAD_STATUSES = new Set(['closed', 'crashed']);
96
+ /**
97
+ * True when a session's status means it is over. Exported so the card can keep
98
+ * the `agents` roster to live sessions: the headline and the `dead` row already
99
+ * separate the two, and a roster that reads `crashed ×25` beside `23 live`
100
+ * makes the reader distrust both numbers.
101
+ */
102
+ export function isDeadStatus(status) {
103
+ return DEAD_STATUSES.has(status);
104
+ }
96
105
  /**
97
106
  * Split a rollup's sessions into what is working and what is wreckage.
98
107
  *
@@ -5,8 +5,17 @@
5
5
  * directory by pure convention (`<projectRoot>/<slug>`, see `project-root.ts`).
6
6
  * This module adds editable definitions on top: one YAML file per project under
7
7
  * `~/.agents/projects/<name>.yaml`, sitting beside the existing `routines/`,
8
- * `monitors/`, and `teams/` dirs in the user repo (so definitions sync across
9
- * machines for free via `agents push/pull`). A defined project can name itself
8
+ * `monitors/`, and `teams/` dirs in the user repo.
9
+ *
10
+ * That location makes definitions SYNCABLE, not automatically synced: they ride
11
+ * the user repo only once they are committed to it, via `agents repo push user`
12
+ * (`agents push` was removed). Until then the directory is untracked, and a
13
+ * reconcile that cleans the working tree deletes it — observed twice on one
14
+ * machine, taking four definitions with it each time. The recovery is an
15
+ * orphaned `chore(local): save …-sync drift` commit, which is not a guarantee:
16
+ * unreachable objects are collected. Say "commit them" rather than "for free".
17
+ *
18
+ * A defined project can name itself
10
19
  * independently of its folder, bind more than one repo, pin a monorepo subpath,
11
20
  * describe context subdirectories an agent should start from, carry a Linear
12
21
  * link and external integrations, and set an explicit default path.
@@ -42,6 +51,19 @@ export interface ProjectContext {
42
51
  /** One line on how this subtree relates to the project. */
43
52
  purpose: string;
44
53
  }
54
+ /**
55
+ * A project goal — the OKR-shaped "why". A project serves one or more goals: a
56
+ * qualitative `objective` ("Ship agents-cli 2.0") and an optional `measure`, the
57
+ * key result that says whether it's landing ("fleet on 2.x", "p95 < 200ms"). The
58
+ * goal is the outcome the work is chasing; milestones (dated checkpoints, pulled
59
+ * from Linear) and live work (agents / PRs / artifacts) are how far along it is.
60
+ */
61
+ export interface ProjectGoal {
62
+ /** The outcome, in a line. */
63
+ objective: string;
64
+ /** Optional key result — how success is measured. */
65
+ measure?: string;
66
+ }
45
67
  /** An external context source hung off the project (surfaced in `projects show`). */
46
68
  export interface ProjectIntegration {
47
69
  /** e.g. `gdrive`, `notion`, `figma`, `url`. */
@@ -64,6 +86,8 @@ export interface ProjectDef {
64
86
  repos?: ProjectRepo[];
65
87
  /** Described starting points inside the project. */
66
88
  contexts?: ProjectContext[];
89
+ /** The outcomes this project serves (OKR-shaped); a project may have several. */
90
+ goals?: ProjectGoal[];
67
91
  /** External context sources (Drive, docs, …). */
68
92
  integrations?: ProjectIntegration[];
69
93
  /** Linear project link — reuses the existing GraphQL path. */
@@ -5,8 +5,17 @@
5
5
  * directory by pure convention (`<projectRoot>/<slug>`, see `project-root.ts`).
6
6
  * This module adds editable definitions on top: one YAML file per project under
7
7
  * `~/.agents/projects/<name>.yaml`, sitting beside the existing `routines/`,
8
- * `monitors/`, and `teams/` dirs in the user repo (so definitions sync across
9
- * machines for free via `agents push/pull`). A defined project can name itself
8
+ * `monitors/`, and `teams/` dirs in the user repo.
9
+ *
10
+ * That location makes definitions SYNCABLE, not automatically synced: they ride
11
+ * the user repo only once they are committed to it, via `agents repo push user`
12
+ * (`agents push` was removed). Until then the directory is untracked, and a
13
+ * reconcile that cleans the working tree deletes it — observed twice on one
14
+ * machine, taking four definitions with it each time. The recovery is an
15
+ * orphaned `chore(local): save …-sync drift` commit, which is not a guarantee:
16
+ * unreachable objects are collected. Say "commit them" rather than "for free".
17
+ *
18
+ * A defined project can name itself
10
19
  * independently of its folder, bind more than one repo, pin a monorepo subpath,
11
20
  * describe context subdirectories an agent should start from, carry a Linear
12
21
  * link and external integrations, and set an explicit default path.
@@ -112,6 +121,18 @@ export function validateProjectDef(raw, sourceName) {
112
121
  return [];
113
122
  });
114
123
  }
124
+ if (Array.isArray(o.goals)) {
125
+ def.goals = o.goals.flatMap((g) => {
126
+ if (g && typeof g === 'object' && typeof g.objective === 'string') {
127
+ const gg = g;
128
+ const goal = { objective: gg.objective };
129
+ if (typeof gg.measure === 'string')
130
+ goal.measure = gg.measure;
131
+ return [goal];
132
+ }
133
+ return [];
134
+ });
135
+ }
115
136
  if (Array.isArray(o.integrations)) {
116
137
  def.integrations = o.integrations.flatMap((i) => {
117
138
  if (i &&
@@ -235,11 +256,40 @@ export function projectBasePath(def, forRemote) {
235
256
  }
236
257
  function projectRootsAbs(defs) {
237
258
  const out = [];
238
- for (const def of defs) {
239
- const raw = def.root ?? def.defaultPath;
259
+ const push = (name, raw) => {
240
260
  if (!raw)
241
- continue;
242
- out.push({ name: def.name, abs: path.resolve(expandLocalHome(raw)) });
261
+ return;
262
+ out.push({ name, abs: path.resolve(expandLocalHome(raw)) });
263
+ };
264
+ for (const def of defs) {
265
+ // `root` says where the CHECKOUT is; `defaultPath` says which work is this
266
+ // project's. For a monorepo subproject those differ, and only the narrower
267
+ // one is a membership claim — a project scoped to `rush/apps/cli` does not
268
+ // own `rush/apps/web`.
269
+ //
270
+ // The old `root ?? defaultPath` collapsed such a subproject onto the
271
+ // monorepo root, the same path its umbrella anchors at, so the longest-match
272
+ // tiebreak below had nothing to separate them and a session in
273
+ // `rush/apps/cli` counted toward whichever definition was listed first.
274
+ const rootAbs = def.root ? path.resolve(expandLocalHome(def.root)) : undefined;
275
+ const defaultAbs = def.defaultPath ? path.resolve(expandLocalHome(def.defaultPath)) : undefined;
276
+ const narrowed = !!(rootAbs && defaultAbs && defaultAbs !== rootAbs && isUnder(defaultAbs, rootAbs));
277
+ // A narrowed project's root is a WEAK claim: it still covers the rest of the
278
+ // checkout when nobody else wants it, but yields to any project that claims
279
+ // a path outright. Dropping it entirely regressed the single-project case —
280
+ // `add foo --root ~/src/foo --path apps/web` stopped attributing work
281
+ // anywhere else in its own repo, and `--path` means where agents START, not
282
+ // which work counts.
283
+ if (rootAbs)
284
+ out.push({ name: def.name, abs: rootAbs, weak: narrowed });
285
+ if (defaultAbs && defaultAbs !== rootAbs)
286
+ out.push({ name: def.name, abs: defaultAbs });
287
+ for (const r of def.repos ?? []) {
288
+ push(def.name, r.path);
289
+ // A repo pinned to a monorepo subpath anchors at that subpath too.
290
+ if (r.path && r.subpath)
291
+ push(def.name, path.join(expandLocalHome(r.path), r.subpath));
292
+ }
243
293
  }
244
294
  return out;
245
295
  }
@@ -269,13 +319,23 @@ export function projectNameForCwd(cwd, defs) {
269
319
  const abs = path.resolve(expandLocalHome(cwd));
270
320
  let best;
271
321
  let bestLen = -1;
272
- for (const { name, abs: root } of projectRootsAbs(defs)) {
273
- if (isUnder(abs, root) && root.length > bestLen) {
322
+ let weakBest;
323
+ let weakLen = -1;
324
+ for (const { name, abs: root, weak } of projectRootsAbs(defs)) {
325
+ if (!isUnder(abs, root))
326
+ continue;
327
+ if (weak) {
328
+ if (root.length > weakLen) {
329
+ weakBest = name;
330
+ weakLen = root.length;
331
+ }
332
+ }
333
+ else if (root.length > bestLen) {
274
334
  best = name;
275
335
  bestLen = root.length;
276
336
  }
277
337
  }
278
- return best;
338
+ return best ?? weakBest;
279
339
  }
280
340
  /**
281
341
  * The canonical project label for a cwd, for every surface that buckets work by
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import type { AgentId, RunStrategy } from './types.js';
8
8
  import type { FallbackEntry } from './exec.js';
9
- import { type AccountInfo } from './agents.js';
9
+ import { type AccountInfo, type CredentialPresence } from './agents.js';
10
10
  import { type UsageSnapshot } from './usage.js';
11
11
  export interface RotateCandidate {
12
12
  agent: AgentId;
@@ -75,6 +75,21 @@ export declare function getProjectRunStrategy(agent: AgentId, startPath: string)
75
75
  export declare function getConfiguredRunStrategy(agent: AgentId, startPath?: string): RunStrategy;
76
76
  /** Persist the global run strategy used by bare `agents run <agent>`. */
77
77
  export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrategy): void;
78
+ /**
79
+ * Whether a version home can actually authenticate a launch.
80
+ *
81
+ * `getAccountInfo` falls back to the active/global HOME when a version home has
82
+ * no credential of its own, so `agents view` still shows who is logged in. Launch
83
+ * paths isolate config (GROK_HOME, CODEX_HOME, KIMI_CODE_HOME, CLAUDE_CONFIG_DIR,
84
+ * …) to the per-version home, so a home that only "inherits" the active login
85
+ * cannot spawn a signed-in agent — balanced kept picking those empty homes and
86
+ * the run died on "Not signed in".
87
+ *
88
+ * When we know where the credential lives (`knownLocation`), require it under
89
+ * THIS version home. When we don't (keychain-only / unmapped agents), trust the
90
+ * existing `signedIn` signal.
91
+ */
92
+ export declare function isLaunchableSignedIn(signedIn: boolean, presence: Pick<CredentialPresence, 'knownLocation' | 'perVersion'>): boolean;
78
93
  /**
79
94
  * How old a usage snapshot may be and still settle a routing DECISION.
80
95
  *
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
- import { accountDisplayLabel, getAccountInfo, ALL_AGENT_IDS } from './agents.js';
9
+ import { accountDisplayLabel, getAccountInfo, credentialPresence, ALL_AGENT_IDS, } from './agents.js';
10
10
  import { readMeta, writeMeta, getHelpersDir } from './state.js';
11
11
  import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
12
12
  import { getProjectRunConfigs } from './run-config.js';
@@ -68,6 +68,27 @@ export function setGlobalRunStrategy(agent, strategy) {
68
68
  function isRotationEligible(candidate) {
69
69
  return candidate.signedIn && hasUsageAvailable(candidate);
70
70
  }
71
+ /**
72
+ * Whether a version home can actually authenticate a launch.
73
+ *
74
+ * `getAccountInfo` falls back to the active/global HOME when a version home has
75
+ * no credential of its own, so `agents view` still shows who is logged in. Launch
76
+ * paths isolate config (GROK_HOME, CODEX_HOME, KIMI_CODE_HOME, CLAUDE_CONFIG_DIR,
77
+ * …) to the per-version home, so a home that only "inherits" the active login
78
+ * cannot spawn a signed-in agent — balanced kept picking those empty homes and
79
+ * the run died on "Not signed in".
80
+ *
81
+ * When we know where the credential lives (`knownLocation`), require it under
82
+ * THIS version home. When we don't (keychain-only / unmapped agents), trust the
83
+ * existing `signedIn` signal.
84
+ */
85
+ export function isLaunchableSignedIn(signedIn, presence) {
86
+ if (!signedIn)
87
+ return false;
88
+ if (!presence.knownLocation)
89
+ return true;
90
+ return presence.perVersion;
91
+ }
71
92
  function isAvailableEligible(candidate) {
72
93
  return isRotationEligible(candidate);
73
94
  }
@@ -484,17 +505,22 @@ export async function collectRunCandidates(agent) {
484
505
  // one per installed version, every time `agents run` cold-starts. If
485
506
  // claude's stored token has actually expired, the spawned agent detects
486
507
  // it at its own startup and re-auths; that's the correct UX.
508
+ //
509
+ // Gate signedIn on a real per-version credential when we know where it
510
+ // lives — see isLaunchableSignedIn. Do not reuse the active-home fallback
511
+ // identity for routing, or empty version homes look healthy and die at spawn.
512
+ const launchable = isLaunchableSignedIn(info.signedIn, credentialPresence(agent, home));
487
513
  return {
488
514
  agent,
489
515
  version,
490
516
  home,
491
517
  info,
492
- accountKey: info.accountKey,
493
- accountLabel: accountDisplayLabel(info),
494
- email: info.email,
495
- usageStatus: info.usageStatus,
496
- plan: info.plan,
497
- signedIn: info.signedIn,
518
+ accountKey: launchable ? info.accountKey : null,
519
+ accountLabel: launchable ? accountDisplayLabel(info) : '',
520
+ email: launchable ? info.email : null,
521
+ usageStatus: launchable ? info.usageStatus : null,
522
+ plan: launchable ? info.plan : null,
523
+ signedIn: launchable,
498
524
  lastActive: info.lastActive,
499
525
  };
500
526
  }));
@@ -344,6 +344,26 @@ export interface RotateOptions {
344
344
  * unless `clearMeta` or a `meta` patch is supplied.
345
345
  */
346
346
  export declare function rotateBundleSecret(bundle: SecretsBundle, key: string, opts: RotateOptions): void;
347
+ /**
348
+ * Reconcile a bundle's keychain-backed VALUE items to its CURRENT policy, then
349
+ * write the (always no-ACL) metadata.
350
+ *
351
+ * `writeBundle` only rewrites the metadata item, so a policy change alone leaves
352
+ * every value item carrying the ACL it was created with — and macOS gates each
353
+ * read on the ITEM's ACL, not the bundle's declared tier (spec SEC-19). Without
354
+ * this reconcile, `agents secrets policy <b> never` reports "silent" while the
355
+ * still-ACL'd value keeps popping Touch ID on every read, forever.
356
+ *
357
+ * hold/always -> never strips the biometry ACL (helper `set-no-acl`: delete+add)
358
+ * never -> hold/always re-attaches it (helper `set`)
359
+ *
360
+ * The current values are read in ONE batch, so the reconcile costs at most a
361
+ * single Touch ID — the last prompt a hold->never bundle will ever raise (a
362
+ * never->* flip reads silently, since the items are already no-ACL). No-op on the
363
+ * ACL to write for non-keychain backends (file/vault have no biometry concept),
364
+ * and a metadata-only write when the bundle has no keychain-backed values.
365
+ */
366
+ export declare function reAclBundleItems(bundle: SecretsBundle): void;
347
367
  /** Options for renameBundle. */
348
368
  export interface RenameOptions {
349
369
  /** When true, overwrite an existing destination bundle (purges its keychain items first). */
@@ -1376,6 +1376,56 @@ export function rotateBundleSecret(bundle, key, opts) {
1376
1376
  }
1377
1377
  writeBundle(bundle);
1378
1378
  }
1379
+ /**
1380
+ * Reconcile a bundle's keychain-backed VALUE items to its CURRENT policy, then
1381
+ * write the (always no-ACL) metadata.
1382
+ *
1383
+ * `writeBundle` only rewrites the metadata item, so a policy change alone leaves
1384
+ * every value item carrying the ACL it was created with — and macOS gates each
1385
+ * read on the ITEM's ACL, not the bundle's declared tier (spec SEC-19). Without
1386
+ * this reconcile, `agents secrets policy <b> never` reports "silent" while the
1387
+ * still-ACL'd value keeps popping Touch ID on every read, forever.
1388
+ *
1389
+ * hold/always -> never strips the biometry ACL (helper `set-no-acl`: delete+add)
1390
+ * never -> hold/always re-attaches it (helper `set`)
1391
+ *
1392
+ * The current values are read in ONE batch, so the reconcile costs at most a
1393
+ * single Touch ID — the last prompt a hold->never bundle will ever raise (a
1394
+ * never->* flip reads silently, since the items are already no-ACL). No-op on the
1395
+ * ACL to write for non-keychain backends (file/vault have no biometry concept),
1396
+ * and a metadata-only write when the bundle has no keychain-backed values.
1397
+ */
1398
+ export function reAclBundleItems(bundle) {
1399
+ if ((bundle.backend ?? 'keychain') !== 'keychain') {
1400
+ // No biometry ACL off the keychain backend — only metadata needs persisting.
1401
+ writeBundle(bundle);
1402
+ return;
1403
+ }
1404
+ const store = itemStore('keychain');
1405
+ // keychainItemsForBundle already returns ONLY keychain-backed value items
1406
+ // (via parseBundleValue), so no extra ref-shape filtering here.
1407
+ const entries = keychainItemsForBundle(bundle);
1408
+ if (entries.length === 0) {
1409
+ // Literal/ref-only bundle: nothing to re-ACL, just refresh metadata.
1410
+ writeBundle(bundle);
1411
+ return;
1412
+ }
1413
+ // One batched read = at most one Touch ID for the whole reconcile.
1414
+ const values = store.getBatch(entries.map((e) => e.item));
1415
+ const rewrite = new Map();
1416
+ for (const { item } of entries) {
1417
+ const value = values.get(item);
1418
+ // A key present in metadata but with no readable value item is real
1419
+ // corruption, not something to silently skip (no fallbacks — fail loud).
1420
+ if (value === undefined) {
1421
+ throw new Error(`Cannot change policy for '${bundle.name}': a keychain value is missing or unreadable. Rotate that key, then retry.`);
1422
+ }
1423
+ rewrite.set(item, value);
1424
+ }
1425
+ // writeBundleWithItems re-stores each value with { noAcl: policy === 'never' }
1426
+ // and the metadata no-ACL (metadata-last), and evicts any broker-held copy.
1427
+ writeBundleWithItems(bundle, rewrite);
1428
+ }
1379
1429
  /**
1380
1430
  * Rename a bundle: move metadata + every keychain-backed value to a new name.
1381
1431
  *
@@ -18,6 +18,7 @@ import { createHash } from 'crypto';
18
18
  import * as fs from 'fs';
19
19
  import * as os from 'os';
20
20
  import * as path from 'path';
21
+ import { copyAppBundle, withInstallLock } from '../app-bundle-install.js';
21
22
  const APP_BUNDLE_NAME = 'Agents CLI.app';
22
23
  const INSTALL_DIR_NAME = 'agents-cli';
23
24
  let installRootOverride = null;
@@ -107,19 +108,6 @@ function spctlAssess(appPath) {
107
108
  });
108
109
  return { ok: r.status === 0, output: (r.stderr || r.stdout || '').toString().trim() };
109
110
  }
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, symlinks, and resource forks.
115
- // `fs.cpSync({recursive: true})` works on simple trees but has historically
116
- // mishandled extended attributes on `.app` bundles, breaking codesign.
117
- const r = spawnSync('cp', ['-R', src, dest], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
118
- if (r.status !== 0) {
119
- const msg = (r.stderr || r.stdout || '').toString().trim();
120
- throw new Error(`Failed to copy ${src} -> ${dest}: ${msg || 'unknown error'}`);
121
- }
122
- }
123
111
  /**
124
112
  * Idempotent install. Copies the bundled `.app` to the stable user path. Skips
125
113
  * if the destination already exists, `codesign --verify` passes, AND the
@@ -135,25 +123,34 @@ function copyAppBundle(src, dest) {
135
123
  export function ensureKeychainHelperInstalled(opts = {}) {
136
124
  assertDarwin();
137
125
  const dest = installedAppPath();
138
- if (!opts.forceReinstall && fs.existsSync(dest)) {
139
- const { ok } = codesignVerify(dest);
140
- if (ok && !installedHelperIsStale())
126
+ const upToDate = () => fs.existsSync(dest) && codesignVerify(dest).ok && !installedHelperIsStale();
127
+ // Fast path: already installed, valid, and current — no lock, no copy.
128
+ if (!opts.forceReinstall && upToDate())
129
+ return;
130
+ // Serialize the install so concurrent `agents` invocations don't race the
131
+ // atomic swap (and don't each run a redundant `cp`). The re-check inside the
132
+ // lock means a burst of callers copies once, not N times — the stampede that
133
+ // transiently corrupted the bundle and tripped the "damaged" dialog.
134
+ withInstallLock(dest, (heartbeat) => {
135
+ if (!opts.forceReinstall && upToDate())
141
136
  return;
142
- }
143
- const src = sourceAppPath();
144
- copyAppBundle(src, dest);
145
- const verify = codesignVerify(dest);
146
- if (!verify.ok) {
147
- throw new Error(`Installed helper failed codesign verification at ${dest}.\n${verify.output}\n` +
148
- 'The bundle may be corrupted. Try `agents helper install` to reinstall, or reinstall agents-cli.');
149
- }
150
- const assess = spctlAssess(dest);
151
- if (!assess.ok) {
152
- // Warn, do not fail. Gatekeeper ticket lookup needs network; offline
153
- // installs and CI runners commonly fail this check. The ACL semantics
154
- // we care about depend on codesign, not spctl.
155
- process.stderr.write(`agents-cli: notarization check (spctl) did not pass for ${dest}: ${assess.output}\n`);
156
- }
137
+ const src = sourceAppPath();
138
+ copyAppBundle(src, dest);
139
+ heartbeat(); // cp -R done; keep the lock fresh across the codesign/spctl spawns
140
+ const verify = codesignVerify(dest);
141
+ if (!verify.ok) {
142
+ throw new Error(`Installed helper failed codesign verification at ${dest}.\n${verify.output}\n` +
143
+ 'The bundle may be corrupted. Try `agents helper install` to reinstall, or reinstall agents-cli.');
144
+ }
145
+ heartbeat(); // codesign done; spctl does a network Gatekeeper lookup — refresh again
146
+ const assess = spctlAssess(dest);
147
+ if (!assess.ok) {
148
+ // Warn, do not fail. Gatekeeper ticket lookup needs network; offline
149
+ // installs and CI runners commonly fail this check. The ACL semantics
150
+ // we care about depend on codesign, not spctl.
151
+ process.stderr.write(`agents-cli: notarization check (spctl) did not pass for ${dest}: ${assess.output}\n`);
152
+ }
153
+ });
157
154
  }
158
155
  /**
159
156
  * Return the absolute path to the helper executable. If the installed bundle
@@ -32,12 +32,20 @@ export declare function readWriteTokenFromBundle(): string;
32
32
  /** Read the raw write token from injected env first, then the local bundle.
33
33
  * Throws with an actionable message if absent (run setup/join first). */
34
34
  export declare function readWriteToken(): string;
35
- /** Best-effort runtime env for spawned agents. Never throws: a missing/locked
36
- * bundle should not block unrelated agent runs, but an already-unlocked bundle
37
- * or injected token lets ephemeral agents publish with no local setup. */
38
- export declare function shareRuntimeEnv(opts?: {
39
- agentOnly?: boolean;
40
- }): Record<string, string> | undefined;
35
+ /** Best-effort runtime env for spawned agents. Never throws AND never prompts.
36
+ *
37
+ * Auto-injecting the share write token on every `agents run` is a background
38
+ * convenience, NOT a user-initiated secret access — so it MUST NOT raise a Touch
39
+ * ID sheet (SEC-13: an agent launch never pops biometry on its own). This was the
40
+ * per-run prompt storm: `share` is a keychain bundle that is rarely broker-held,
41
+ * so an interactive read here spawned the helper and popped Touch ID on EVERY
42
+ * launch. The read is now always `agentOnly` — it resolves the token only from the
43
+ * injected env or an already-held / no-ACL bundle, and silently returns undefined
44
+ * otherwise (the caller runs without auto-share; the agent can still publish via
45
+ * its own `agents share`). To get zero-friction auto-share with no prompt: unlock
46
+ * once (`agents secrets unlock share`) or make it no-ACL (`agents secrets policy
47
+ * share never`). */
48
+ export declare function shareRuntimeEnv(): Record<string, string> | undefined;
41
49
  /** Cloudflare API credentials for provisioning, read from `cloudflare` (or a
42
50
  * user-named bundle). Fuzzy-matches key names so it works across bundle layouts. */
43
51
  export declare function readCloudflareCreds(bundle?: string, override?: {
@@ -56,6 +56,14 @@ export function storeWriteToken(token) {
56
56
  bundle = {
57
57
  name: SHARE_BUNDLE,
58
58
  description: 'agents share — write token for the R2 share endpoint',
59
+ // A NEW share bundle defaults to the `never` tier (no biometry ACL). The R2
60
+ // write token is low-sensitivity automation infra that is auto-read on EVERY
61
+ // `agents run` (shareRuntimeEnv) — a biometry ACL there is what produced the
62
+ // per-run Touch ID storm. `never` stores it no-ACL so auto-share is silent
63
+ // and needs no unlock. An EXISTING bundle keeps its tier (we never silently
64
+ // downgrade one the user already made); change it explicitly with
65
+ // `agents secrets policy share <tier>`.
66
+ policy: 'never',
59
67
  vars: {},
60
68
  };
61
69
  }
@@ -82,10 +90,20 @@ export function readWriteTokenFromBundle() {
82
90
  export function readWriteToken() {
83
91
  return readWriteTokenEnv() ?? readWriteTokenFromBundle();
84
92
  }
85
- /** Best-effort runtime env for spawned agents. Never throws: a missing/locked
86
- * bundle should not block unrelated agent runs, but an already-unlocked bundle
87
- * or injected token lets ephemeral agents publish with no local setup. */
88
- export function shareRuntimeEnv(opts = {}) {
93
+ /** Best-effort runtime env for spawned agents. Never throws AND never prompts.
94
+ *
95
+ * Auto-injecting the share write token on every `agents run` is a background
96
+ * convenience, NOT a user-initiated secret access — so it MUST NOT raise a Touch
97
+ * ID sheet (SEC-13: an agent launch never pops biometry on its own). This was the
98
+ * per-run prompt storm: `share` is a keychain bundle that is rarely broker-held,
99
+ * so an interactive read here spawned the helper and popped Touch ID on EVERY
100
+ * launch. The read is now always `agentOnly` — it resolves the token only from the
101
+ * injected env or an already-held / no-ACL bundle, and silently returns undefined
102
+ * otherwise (the caller runs without auto-share; the agent can still publish via
103
+ * its own `agents share`). To get zero-friction auto-share with no prompt: unlock
104
+ * once (`agents secrets unlock share`) or make it no-ACL (`agents secrets policy
105
+ * share never`). */
106
+ export function shareRuntimeEnv() {
89
107
  if (!readShareConfig())
90
108
  return undefined;
91
109
  const fromEnv = readWriteTokenEnv();
@@ -97,7 +115,7 @@ export function shareRuntimeEnv(opts = {}) {
97
115
  const { env } = readAndResolveBundleEnv(SHARE_BUNDLE, {
98
116
  caller: 'share',
99
117
  keys: [SHARE_TOKEN_KEY],
100
- agentOnly: opts.agentOnly,
118
+ agentOnly: true, // never raise a Touch ID sheet on an agent launch (SEC-13)
101
119
  });
102
120
  const token = env[SHARE_TOKEN_KEY];
103
121
  return token ? { [SHARE_TOKEN_ENV_KEY]: token } : undefined;
@@ -764,6 +764,16 @@ export interface Meta {
764
764
  */
765
765
  isolatedAgents?: Partial<Record<AgentId, string>>;
766
766
  run?: RunConfig;
767
+ /**
768
+ * Daemon watchdog config. `rotate` (default `on`) lets the watchdog rotate a
769
+ * rate-limited session IN PLACE onto a healthy account/harness via
770
+ * `agents run auto` — see lib/watchdog/rotate.ts. Set `off` to keep the
771
+ * nudge-only behavior (the Factory `agents.watchdog.autoRotate: false`
772
+ * migration writes `off` here).
773
+ */
774
+ watchdog?: {
775
+ rotate?: 'on' | 'off';
776
+ };
767
777
  /**
768
778
  * `agents run --lease` config. `secretsBundle` names the keychain secrets bundle
769
779
  * whose provider token (e.g. `HCLOUD_TOKEN`) crabbox uses to reach the cloud API.