@phnx-labs/agents-cli 1.20.77 → 1.20.82

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 (212) hide show
  1. package/CHANGELOG.md +589 -0
  2. package/README.md +27 -25
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +9 -0
  5. package/dist/commands/activity.js +153 -24
  6. package/dist/commands/apply.js +11 -4
  7. package/dist/commands/cli.js +1 -1
  8. package/dist/commands/commands.d.ts +1 -1
  9. package/dist/commands/commands.js +7 -6
  10. package/dist/commands/defaults.js +3 -11
  11. package/dist/commands/doctor.d.ts +74 -0
  12. package/dist/commands/doctor.js +718 -40
  13. package/dist/commands/events.js +1 -1
  14. package/dist/commands/exec.js +53 -8
  15. package/dist/commands/feed.d.ts +4 -0
  16. package/dist/commands/feed.js +87 -11
  17. package/dist/commands/harness.js +2 -2
  18. package/dist/commands/hooks.js +1 -1
  19. package/dist/commands/import.js +8 -4
  20. package/dist/commands/logs.js +6 -0
  21. package/dist/commands/models.js +1 -1
  22. package/dist/commands/packages.js +6 -6
  23. package/dist/commands/permissions.js +1 -1
  24. package/dist/commands/profiles.js +1 -1
  25. package/dist/commands/pull.d.ts +1 -1
  26. package/dist/commands/pull.js +4 -4
  27. package/dist/commands/repo.d.ts +78 -0
  28. package/dist/commands/repo.js +195 -11
  29. package/dist/commands/routines.js +16 -4
  30. package/dist/commands/rules.js +13 -12
  31. package/dist/commands/secrets.d.ts +3 -1
  32. package/dist/commands/secrets.js +108 -34
  33. package/dist/commands/sessions-browser.d.ts +45 -3
  34. package/dist/commands/sessions-browser.js +118 -12
  35. package/dist/commands/sessions-export.d.ts +3 -0
  36. package/dist/commands/sessions-export.js +13 -10
  37. package/dist/commands/sessions-inject.d.ts +7 -4
  38. package/dist/commands/sessions-inject.js +18 -18
  39. package/dist/commands/sessions-picker.d.ts +15 -1
  40. package/dist/commands/sessions-picker.js +90 -7
  41. package/dist/commands/sessions-resume.d.ts +16 -0
  42. package/dist/commands/sessions-resume.js +14 -13
  43. package/dist/commands/sessions.d.ts +126 -1
  44. package/dist/commands/sessions.js +365 -42
  45. package/dist/commands/set.d.ts +15 -0
  46. package/dist/commands/set.js +79 -0
  47. package/dist/commands/setup-fleet.d.ts +21 -0
  48. package/dist/commands/setup-fleet.js +201 -0
  49. package/dist/commands/setup-secrets.d.ts +20 -0
  50. package/dist/commands/setup-secrets.js +223 -0
  51. package/dist/commands/setup.js +17 -3
  52. package/dist/commands/skills.js +1 -1
  53. package/dist/commands/ssh.js +9 -1
  54. package/dist/commands/sync.js +7 -2
  55. package/dist/commands/teams-picker.js +0 -1
  56. package/dist/commands/teams.d.ts +33 -1
  57. package/dist/commands/teams.js +208 -44
  58. package/dist/commands/versions.js +10 -5
  59. package/dist/commands/view.d.ts +1 -0
  60. package/dist/commands/view.js +11 -0
  61. package/dist/index.js +8 -13
  62. package/dist/lib/acp/harnesses.js +0 -7
  63. package/dist/lib/activity.d.ts +156 -0
  64. package/dist/lib/activity.js +282 -0
  65. package/dist/lib/agents.d.ts +9 -23
  66. package/dist/lib/agents.js +72 -100
  67. package/dist/lib/auto-pull.d.ts +16 -8
  68. package/dist/lib/auto-pull.js +23 -28
  69. package/dist/lib/browser/service.js +3 -0
  70. package/dist/lib/browser/types.d.ts +7 -0
  71. package/dist/lib/capabilities.js +6 -2
  72. package/dist/lib/crabbox/lease.js +2 -2
  73. package/dist/lib/crabbox/setup-copy.d.ts +4 -4
  74. package/dist/lib/crabbox/setup-copy.js +4 -4
  75. package/dist/lib/daemon.d.ts +26 -40
  76. package/dist/lib/daemon.js +63 -123
  77. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  78. package/dist/lib/devices/fleet-divergence.js +188 -0
  79. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  80. package/dist/lib/devices/fleet-inventory.js +57 -0
  81. package/dist/lib/devices/fleet.d.ts +1 -1
  82. package/dist/lib/devices/fleet.js +1 -1
  83. package/dist/lib/devices/health-report.d.ts +10 -2
  84. package/dist/lib/devices/health-report.js +32 -1
  85. package/dist/lib/doctor-diff.d.ts +20 -0
  86. package/dist/lib/doctor-diff.js +6 -1
  87. package/dist/lib/drift.d.ts +31 -11
  88. package/dist/lib/drift.js +58 -7
  89. package/dist/lib/events.d.ts +8 -1
  90. package/dist/lib/events.js +10 -1
  91. package/dist/lib/exec.js +35 -8
  92. package/dist/lib/feed-post.d.ts +28 -1
  93. package/dist/lib/feed-post.js +110 -2
  94. package/dist/lib/fleet/apply.js +8 -1
  95. package/dist/lib/fleet/auth-sync.d.ts +18 -1
  96. package/dist/lib/fleet/auth-sync.js +25 -11
  97. package/dist/lib/fleet/remote-login.js +5 -0
  98. package/dist/lib/git.d.ts +27 -14
  99. package/dist/lib/git.js +138 -43
  100. package/dist/lib/hooks.d.ts +42 -0
  101. package/dist/lib/hooks.js +137 -93
  102. package/dist/lib/hosts/dispatch.d.ts +28 -1
  103. package/dist/lib/hosts/dispatch.js +33 -5
  104. package/dist/lib/hosts/option.js +2 -2
  105. package/dist/lib/hosts/passthrough.d.ts +22 -1
  106. package/dist/lib/hosts/passthrough.js +238 -13
  107. package/dist/lib/hosts/remote-cmd.js +2 -0
  108. package/dist/lib/hosts/run-target.d.ts +2 -0
  109. package/dist/lib/hosts/run-target.js +1 -0
  110. package/dist/lib/hq/floor.d.ts +1 -1
  111. package/dist/lib/hq/floor.js +6 -1
  112. package/dist/lib/mcp.js +3 -71
  113. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  114. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  115. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  116. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  117. package/dist/lib/menubar/notify-desktop.d.ts +15 -0
  118. package/dist/lib/menubar/notify-desktop.js +41 -5
  119. package/dist/lib/merged-resources.d.ts +11 -0
  120. package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
  121. package/dist/lib/permissions.d.ts +0 -32
  122. package/dist/lib/permissions.js +4 -157
  123. package/dist/lib/picker.js +1 -1
  124. package/dist/lib/platform/process.d.ts +16 -5
  125. package/dist/lib/platform/process.js +54 -0
  126. package/dist/lib/plugins.d.ts +0 -8
  127. package/dist/lib/plugins.js +4 -110
  128. package/dist/lib/project-resources.js +4 -1
  129. package/dist/lib/refresh.d.ts +5 -3
  130. package/dist/lib/refresh.js +7 -5
  131. package/dist/lib/resources/commands.js +4 -1
  132. package/dist/lib/resources/mcp.js +0 -4
  133. package/dist/lib/resources/permissions.d.ts +1 -1
  134. package/dist/lib/resources/permissions.js +1 -5
  135. package/dist/lib/resources/rules.js +4 -1
  136. package/dist/lib/resources/skills.js +4 -1
  137. package/dist/lib/resources/subagents.js +4 -0
  138. package/dist/lib/resources/types.d.ts +1 -1
  139. package/dist/lib/routines.d.ts +20 -0
  140. package/dist/lib/routines.js +6 -0
  141. package/dist/lib/run-defaults.d.ts +1 -0
  142. package/dist/lib/run-defaults.js +9 -0
  143. package/dist/lib/runner.d.ts +0 -12
  144. package/dist/lib/runner.js +71 -29
  145. package/dist/lib/sandbox.js +0 -16
  146. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  147. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  148. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  149. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  150. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  151. package/dist/lib/secrets/agent.d.ts +18 -0
  152. package/dist/lib/secrets/agent.js +131 -17
  153. package/dist/lib/secrets/audit.d.ts +46 -0
  154. package/dist/lib/secrets/audit.js +56 -0
  155. package/dist/lib/secrets/bundles.d.ts +12 -9
  156. package/dist/lib/secrets/bundles.js +47 -31
  157. package/dist/lib/secrets/index.d.ts +2 -1
  158. package/dist/lib/secrets/index.js +8 -2
  159. package/dist/lib/secrets/remote.d.ts +75 -2
  160. package/dist/lib/secrets/remote.js +139 -5
  161. package/dist/lib/secrets/scope.d.ts +26 -0
  162. package/dist/lib/secrets/scope.js +29 -0
  163. package/dist/lib/secrets/session-store.d.ts +10 -0
  164. package/dist/lib/secrets/session-store.js +64 -11
  165. package/dist/lib/secrets/sync.js +3 -3
  166. package/dist/lib/session/active.d.ts +117 -26
  167. package/dist/lib/session/active.js +199 -53
  168. package/dist/lib/session/actor-sidecar.d.ts +23 -0
  169. package/dist/lib/session/actor-sidecar.js +101 -0
  170. package/dist/lib/session/db.d.ts +41 -1
  171. package/dist/lib/session/db.js +173 -8
  172. package/dist/lib/session/digest.js +126 -21
  173. package/dist/lib/session/discover.d.ts +19 -2
  174. package/dist/lib/session/discover.js +32 -11
  175. package/dist/lib/session/hook-sessions.d.ts +9 -0
  176. package/dist/lib/session/hook-sessions.js +57 -8
  177. package/dist/lib/session/origin-machine.d.ts +18 -0
  178. package/dist/lib/session/origin-machine.js +34 -0
  179. package/dist/lib/session/pid-registry.d.ts +13 -0
  180. package/dist/lib/session/render.d.ts +2 -0
  181. package/dist/lib/session/render.js +1 -1
  182. package/dist/lib/session/state.d.ts +1 -0
  183. package/dist/lib/session/state.js +1 -1
  184. package/dist/lib/session/sync/config.js +2 -2
  185. package/dist/lib/session/types.d.ts +16 -0
  186. package/dist/lib/smart-launch.d.ts +86 -0
  187. package/dist/lib/smart-launch.js +172 -0
  188. package/dist/lib/staleness/detectors/commands.js +2 -2
  189. package/dist/lib/staleness/detectors/permissions.js +1 -45
  190. package/dist/lib/staleness/writers/commands.js +4 -5
  191. package/dist/lib/staleness/writers/hooks.js +1 -1
  192. package/dist/lib/startup/command-registry.d.ts +1 -2
  193. package/dist/lib/startup/command-registry.js +2 -4
  194. package/dist/lib/subagents-registry.js +5 -12
  195. package/dist/lib/subagents.d.ts +0 -10
  196. package/dist/lib/subagents.js +0 -12
  197. package/dist/lib/teams/agents.d.ts +0 -2
  198. package/dist/lib/teams/agents.js +10 -27
  199. package/dist/lib/teams/index.d.ts +1 -1
  200. package/dist/lib/teams/index.js +1 -1
  201. package/dist/lib/types.d.ts +17 -9
  202. package/dist/lib/usage.d.ts +28 -5
  203. package/dist/lib/usage.js +271 -8
  204. package/dist/lib/versions.js +7 -1
  205. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  206. package/dist/lib/watchdog/watchdog.js +25 -13
  207. package/package.json +1 -2
  208. package/dist/commands/check.d.ts +0 -15
  209. package/dist/commands/check.js +0 -180
  210. package/dist/commands/resources.d.ts +0 -5
  211. package/dist/lib/secrets/account-token.d.ts +0 -20
  212. package/dist/lib/secrets/account-token.js +0 -64
@@ -23,13 +23,12 @@ import { prepareJobHome, buildSpawnEnv, getJobHomePath } from './sandbox.js';
23
23
  import { resolveModel, buildReasoningFlags } from './models.js';
24
24
  import { createTimer, maybeRotate, redactPrompt } from './events.js';
25
25
  import { normalizeMode, resolveHeadlessMode, buildExecEnv, detectRateLimit, detectAuthFailure, isAuthFailureFromLog, authFailureReason, } from './exec.js';
26
+ import { resolveActor } from './actor.js';
26
27
  import { loadTask as loadHostTask } from './hosts/tasks.js';
27
28
  import { reconcileTask as reconcileHostTask } from './hosts/reconcile.js';
28
29
  import { backgroundSpawnOptions } from './platform/process.js';
29
30
  import { walkForFiles } from './fs-walk.js';
30
- import { getBinaryPath, getVersionHomePath, isVersionInstalled, resolveVersion } from './versions.js';
31
- import { claudeHomeHasOwnCredential } from './agents.js';
32
- import { resolveAccountSetupToken } from './secrets/account-token.js';
31
+ import { getBinaryPath, isVersionInstalled, resolveVersion } from './versions.js';
33
32
  import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
34
33
  import { readAuthHealth, isDeadVerdict } from './auth-health.js';
35
34
  import { machineId } from './machine-id.js';
@@ -400,29 +399,22 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone) {
400
399
  if (v !== undefined)
401
400
  out[k] = v;
402
401
  }
403
- // A headless routine should authenticate the rotation-pinned account via its
404
- // long-lived, NON-rotating setup-token, not the interactive OAuth session.
405
- // Claude Code's interactive session uses single-use rotating refresh tokens: one
406
- // fleet machine refreshing invalidates that account on every other machine, so
407
- // they 401 and log out (Claude Code #25609 / #56339) — the fleet-wide daily
408
- // logout. If the daemon injected this account's per-account
409
- // CLAUDE_CODE_OAUTH_TOKEN_<slug> (from a headless-readable no-ACL bundle), use it;
410
- // that also works on macOS, where the drop path below is inert
411
- // (claudeHomeHasOwnCredential is false on darwin, agents.ts).
402
+ // A routine authenticates through the pinned account's own CLAUDE_CONFIG_DIR
403
+ // login on THIS box (buildExecEnv points it at the per-account version home).
404
+ // Claude Code's interactive session refreshes itself per-device; keeping the
405
+ // daemon out of the credential entirely is what avoids the fleet-wide rotation
406
+ // logout a shared/rotating token was the cause, not the fix.
412
407
  //
413
- // Fallback (RUSH-1979): with no per-account token, keep the prior behavior — drop
414
- // the single ambient CLAUDE_CODE_OAUTH_TOKEN when the pinned account has its own
415
- // on-disk credential (Linux) so it isn't shadowed; keep it otherwise.
416
- if (agent === 'claude' && version && out.CLAUDE_CONFIG_DIR) {
417
- const home = getVersionHomePath('claude', version);
418
- const accountToken = resolveAccountSetupToken(baseEnv, home);
419
- if (accountToken) {
420
- out.CLAUDE_CODE_OAUTH_TOKEN = accountToken;
421
- }
422
- else if (claudeHomeHasOwnCredential(home)) {
423
- delete out.CLAUDE_CODE_OAUTH_TOKEN;
424
- }
425
- }
408
+ // Injecting a token was already ruled out, but INHERITING one was not:
409
+ // buildExecEnv spreads the ambient process.env (exec.ts) and sanitizeProcessEnv
410
+ // only strips loader/interpreter vars, never credentials. So on any box whose
411
+ // daemon environment happens to carry CLAUDE_CODE_OAUTH_TOKEN, every routine
412
+ // spawn silently ran on that one shared rotating token instead of the host's
413
+ // own login — the exact fleet-wide-logout path, arriving by inheritance rather
414
+ // than injection. CI never caught it because CI has no token to inherit; a
415
+ // provisioned box does. Drop it here so a routine always uses the login of the
416
+ // machine it runs on.
417
+ delete out.CLAUDE_CODE_OAUTH_TOKEN;
426
418
  if (timezone)
427
419
  out.TZ = timezone;
428
420
  return out;
@@ -518,6 +510,28 @@ function spawnJobAttempt(cmd, env, attemptLogPath, timeoutMs, combinedLogPath) {
518
510
  * Single-shot path: pre-flight version/account selection + mid-run rate-limit
519
511
  * failover across healthy same-agent accounts (RUSH-1016).
520
512
  */
513
+ /**
514
+ * Actor provenance for a routine run (RUSH-2020): `actor` is the routine's
515
+ * CREATOR (carried from the job config), `triggeredBy` is whoever kicked off THIS
516
+ * run (`resolveActor().id` — a person for a manual run, `UNRESOLVED@<host>` for an
517
+ * unattended scheduled fire). Spread into every RunMeta so a fired cron traces
518
+ * back to the person who scheduled it.
519
+ */
520
+ function runProvenance(config) {
521
+ return { ...(config.actor ? { actor: config.actor } : {}), triggeredBy: resolveActor().id };
522
+ }
523
+ /**
524
+ * Inject the routine creator's actor into a run's base env so the fired agent
525
+ * INHERITS it (the `AGENTS_ACTOR` path in actor.ts) instead of re-resolving to
526
+ * `UNRESOLVED@<host>` on an unattended fire — so its session, events, and commits
527
+ * all attribute to the person who scheduled the routine (RUSH-2020). Mutates and
528
+ * returns the same env object for call-site brevity.
529
+ */
530
+ function injectRoutineActor(env, config) {
531
+ if (config.actor && !env.AGENTS_ACTOR)
532
+ env.AGENTS_ACTOR = config.actor;
533
+ return env;
534
+ }
521
535
  export async function executeJob(config, deps) {
522
536
  const eligibility = checkJobDeviceEligibility(config);
523
537
  if (eligibility) {
@@ -564,9 +578,9 @@ export async function executeJob(config, deps) {
564
578
  const runId = generateRunId();
565
579
  const runDir = getRunDir(config.name, runId);
566
580
  fs.mkdirSync(runDir, { recursive: true });
567
- const baseEnv = useSandbox
581
+ const baseEnv = injectRoutineActor(useSandbox
568
582
  ? buildSpawnEnv(overlayHome)
569
- : { ...process.env };
583
+ : { ...process.env }, config);
570
584
  // Workflows run via `agents run <workflow>` which delegates to claude under the hood.
571
585
  // Use 'claude' as the effective agent for report extraction and metadata when workflow is set.
572
586
  // (command jobs branched out earlier, so config.agent is set on the non-workflow path.)
@@ -574,6 +588,7 @@ export async function executeJob(config, deps) {
574
588
  const meta = {
575
589
  jobName: config.name,
576
590
  runId,
591
+ ...runProvenance(config),
577
592
  agent: effectiveAgent,
578
593
  ...(config.workflow ? { workflow: config.workflow } : {}),
579
594
  pid: null,
@@ -789,6 +804,7 @@ async function executeJobOnCloud(config, opts) {
789
804
  const meta = {
790
805
  jobName: config.name,
791
806
  runId,
807
+ ...runProvenance(config),
792
808
  agent: config.agent,
793
809
  pid: null,
794
810
  spawnedAt: Date.now(),
@@ -860,6 +876,7 @@ async function executeJobOnHost(config, opts) {
860
876
  const meta = {
861
877
  jobName: config.name,
862
878
  runId,
879
+ ...runProvenance(config),
863
880
  agent: config.agent,
864
881
  pid: null, // no local process — the run lives on the host
865
882
  spawnedAt: Date.now(),
@@ -907,6 +924,7 @@ async function executeCommandJobForeground(config) {
907
924
  const meta = {
908
925
  jobName: config.name,
909
926
  runId,
927
+ ...runProvenance(config),
910
928
  command: config.command,
911
929
  pid: null,
912
930
  spawnedAt: Date.now(),
@@ -1038,9 +1056,9 @@ export async function executeJobDetached(config, hooks) {
1038
1056
  fs.mkdirSync(runDir, { recursive: true });
1039
1057
  const stdoutPath = path.join(runDir, 'stdout.log');
1040
1058
  const stdoutFd = fs.openSync(stdoutPath, 'w', 0o600);
1041
- const baseEnv = useSandbox
1059
+ const baseEnv = injectRoutineActor(useSandbox
1042
1060
  ? buildSpawnEnv(overlayHome)
1043
- : { ...process.env };
1061
+ : { ...process.env }, config);
1044
1062
  const spawnEnv = dispatchesViaAgentsRun(config)
1045
1063
  ? (() => {
1046
1064
  const e = { ...baseEnv };
@@ -1054,6 +1072,7 @@ export async function executeJobDetached(config, hooks) {
1054
1072
  const meta = {
1055
1073
  jobName: config.name,
1056
1074
  runId,
1075
+ ...runProvenance(config),
1057
1076
  agent: effectiveAgent,
1058
1077
  ...(config.workflow ? { workflow: config.workflow } : {}),
1059
1078
  pid: null,
@@ -1063,6 +1082,28 @@ export async function executeJobDetached(config, hooks) {
1063
1082
  completedAt: null,
1064
1083
  exitCode: null,
1065
1084
  };
1085
+ // Auth preflight (mirrors executeJob): with no injected token, a daemon-fired
1086
+ // Claude routine authenticates via the pinned account's own CLAUDE_CONFIG_DIR
1087
+ // login. If the last live probe rejected that (agent, version)'s token, the run
1088
+ // is guaranteed to 401 — fail fast with a re-login hint instead of spawning a
1089
+ // doomed run + poisoned report. Cache-only; fails OPEN on any non-dead/missing
1090
+ // verdict, and agents with no live probe (codex/gemini/grok) are never blocked.
1091
+ const preflightVersion = launch.chain[0]?.version;
1092
+ if (preflightVersion) {
1093
+ const health = readAuthHealth(machineId(), effectiveAgent, preflightVersion);
1094
+ if (health && isDeadVerdict(health.verdict)) {
1095
+ const reason = `auth_preflight: ${health.verdict}`;
1096
+ process.stderr.write(`[agents] routine ${config.name}: ${effectiveAgent}@${preflightVersion} token ${health.verdict} — skipping run (re-login required)\n`);
1097
+ try {
1098
+ fs.closeSync(stdoutFd);
1099
+ }
1100
+ catch { /* already closed */ }
1101
+ finalizeRunMeta(meta, 'failed', 1, { errorMessage: reason });
1102
+ writeRunMeta(meta);
1103
+ archiveRoutineTranscripts(meta, runDir, overlayHome);
1104
+ return meta;
1105
+ }
1106
+ }
1066
1107
  const child = spawn(cmd[0], cmd.slice(1), {
1067
1108
  stdio: ['ignore', stdoutFd, stdoutFd],
1068
1109
  ...backgroundSpawnOptions({ fdStdio: true }),
@@ -1152,6 +1193,7 @@ function executeCommandJobDetached(config, hooks) {
1152
1193
  const meta = {
1153
1194
  jobName: config.name,
1154
1195
  runId,
1196
+ ...runProvenance(config),
1155
1197
  command: config.command,
1156
1198
  pid: null,
1157
1199
  spawnedAt: Date.now(),
@@ -44,12 +44,6 @@ const ENV_ALLOWLIST = [
44
44
  'VISUAL',
45
45
  'NO_COLOR',
46
46
  'FORCE_COLOR',
47
- // Headless Claude auth: the routines daemon injects CLAUDE_CODE_OAUTH_TOKEN
48
- // from the `claude` secrets bundle (see daemon.ts). Without this allowlist
49
- // entry, sandboxed routine spawns drop the token and look "unconfigured".
50
- // Intentionally NOT ANTHROPIC_API_KEY / other provider secrets — those stay
51
- // stripped (see tests/sandbox.test.ts "does not include sensitive env vars").
52
- 'CLAUDE_CODE_OAUTH_TOKEN',
53
47
  ];
54
48
  /** Tools safe to grant as wildcards (no filesystem access). */
55
49
  const SAFE_TOOLS = {
@@ -75,16 +69,6 @@ export function buildSpawnEnv(overlayHome, extraEnv) {
75
69
  env[key] = process.env[key];
76
70
  }
77
71
  }
78
- // Per-account Claude setup-tokens: the daemon injects a CLAUDE_CODE_OAUTH_TOKEN_<slug>
79
- // for each account so a routine authenticates its rotation-pinned account via a
80
- // long-lived, non-rotating token (see runner.ts buildRoutineSpawnEnv). Forward every
81
- // such key by prefix — same trust tier as CLAUDE_CODE_OAUTH_TOKEN above; still no API
82
- // keys or other provider secrets.
83
- for (const key of Object.keys(process.env)) {
84
- if (key.startsWith('CLAUDE_CODE_OAUTH_TOKEN_') && process.env[key]) {
85
- env[key] = process.env[key];
86
- }
87
- }
88
72
  if (extraEnv) {
89
73
  Object.assign(env, extraEnv);
90
74
  }
@@ -8,6 +8,8 @@
8
8
  <string>Agents CLI</string>
9
9
  <key>CFBundleDisplayName</key>
10
10
  <string>Agents CLI</string>
11
+ <key>CFBundleIconFile</key>
12
+ <string>AppIcon</string>
11
13
  <key>CFBundleExecutable</key>
12
14
  <string>Agents CLI</string>
13
15
  <key>CFBundlePackageType</key>
@@ -3,9 +3,21 @@
3
3
  <plist version="1.0">
4
4
  <dict>
5
5
  <key>files</key>
6
- <dict/>
6
+ <dict>
7
+ <key>Resources/AppIcon.icns</key>
8
+ <data>
9
+ Vd5nfogg74zSRNspluv4uvDecUA=
10
+ </data>
11
+ </dict>
7
12
  <key>files2</key>
8
13
  <dict>
14
+ <key>Resources/AppIcon.icns</key>
15
+ <dict>
16
+ <key>hash2</key>
17
+ <data>
18
+ DznVe0VgYOux7+B/aiHNgGYLCvSJKzgXDTPJn2jNkNg=
19
+ </data>
20
+ </dict>
9
21
  <key>embedded.provisionprofile</key>
10
22
  <dict>
11
23
  <key>hash2</key>
@@ -25,6 +25,8 @@
25
25
  */
26
26
  import * as net from 'net';
27
27
  import type { SecretsBundle } from './bundles.js';
28
+ import { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
29
+ export { GLOBAL_HARNESS, bundleScopeChain };
28
30
  /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
29
31
  export declare const DEFAULT_TTL_MS: number;
30
32
  /**
@@ -204,6 +206,22 @@ type BrokerConnectionHandler = (conn: net.Socket) => void;
204
206
  * rotation on broker restart is picked up without rebuilding the handler.
205
207
  */
206
208
  export declare function makeConnectionHandler(handle: (req: Request) => Response, token: () => string | null): BrokerConnectionHandler;
209
+ /**
210
+ * Whether a live process still owns the broker pid file.
211
+ *
212
+ * A single missed `agentPing` is NOT proof the owner is dead — the broker is
213
+ * single-threaded, so a big `get` or the rehydrate at startup can blow the
214
+ * 700ms ping budget while the process is perfectly healthy. Treating that as
215
+ * death let a starting broker unlink a live socket and rebind, leaving the old
216
+ * process alive holding every unlocked bundle in RAM that no client could reach
217
+ * any more (observed live: two brokers, one socket path, two kernel sockets).
218
+ * The pid file is the second, independent liveness signal that makes the reclaim
219
+ * safe.
220
+ */
221
+ /** Drop our ownership record, but only if it is still ours — never clobber a
222
+ * successor that already claimed the socket. */
223
+ export declare function releaseBrokerPid(): void;
224
+ export declare function brokerPidAlive(): boolean;
207
225
  /**
208
226
  * Run the broker in the foreground. Spawned detached by ensureAgentRunning via
209
227
  * `agents secrets _agent-run`. Holds the store in memory, serves the socket,
@@ -30,12 +30,18 @@ import * as path from 'path';
30
30
  import { randomBytes } from 'crypto';
31
31
  import { spawn, spawnSync, execFileSync } from 'child_process';
32
32
  import { getHelpersDir, readMeta } from '../state.js';
33
- import { isAlive } from '../platform/process.js';
33
+ import { isAlive, waitForExit } from '../platform/process.js';
34
34
  import { getKeychainHelperPath } from './install-helper.js';
35
35
  import { getCliVersion, getCliVersionFresh } from '../version.js';
36
36
  import { getCliLaunch } from '../cli-entry.js';
37
+ import { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
37
38
  import { rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
38
39
  import { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './sync-commands.js';
40
+ // Re-exported so callers already reaching for agent.js keep one obvious home for
41
+ // the scope vocabulary; the definitions live in the leaf module scope.ts because
42
+ // agent.ts and session-store.ts import each other and a cyclic `const` read can
43
+ // hit the ESM temporal dead zone at runtime.
44
+ export { GLOBAL_HARNESS, bundleScopeChain };
39
45
  /** Bumped when the wire protocol changes; a client that pings a mismatched
40
46
  * server kills and respawns it rather than talking a stale dialect. */
41
47
  const PROTOCOL_VERSION = 2;
@@ -59,6 +65,19 @@ export const META_CACHE_PREFIX = '!meta:';
59
65
  const IDLE_EXIT_MS = 5 * 60 * 1000; // 5m
60
66
  /** How often the broker sweeps expired entries. */
61
67
  const SWEEP_INTERVAL_MS = 30 * 1000;
68
+ /** How many times a starting broker probes an in-use socket before treating its
69
+ * owner as gone. One probe is not enough: the broker is single-threaded, so a
70
+ * large read or the startup rehydrate can outlast a single ping budget while the
71
+ * process is healthy. */
72
+ const BIND_PROBE_ATTEMPTS = 3;
73
+ /** How long a broker teardown waits for the old process to actually exit before
74
+ * clearing its socket + ownership record. */
75
+ const BROKER_STOP_GRACE_MS = 3000;
76
+ /** Pause between those probes. */
77
+ const BIND_PROBE_INTERVAL_MS = 250;
78
+ function delay(ms) {
79
+ return new Promise((resolve) => setTimeout(resolve, ms));
80
+ }
62
81
  /**
63
82
  * Timeouts for the three synchronous broker clients, split across the process
64
83
  * boundary they now straddle.
@@ -133,7 +152,7 @@ function onDarwin() {
133
152
  function rehydrateStore(now = Date.now()) {
134
153
  const store = new Map();
135
154
  for (const { name, entry } of rehydrateSessions(now)) {
136
- const harness = entry.harness || 'cli';
155
+ const harness = entry.harness || GLOBAL_HARNESS;
137
156
  store.set(scopedBundleKey(name, harness), { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt, harness });
138
157
  }
139
158
  return store;
@@ -164,6 +183,20 @@ function pidPath() {
164
183
  function tokenPath() {
165
184
  return path.join(agentDir(), 'agent.token');
166
185
  }
186
+ /**
187
+ * Path of the CURRENT SOCKET OWNER's pid.
188
+ *
189
+ * Deliberately NOT `agent.pid`: that file is the standalone service's O_EXCL
190
+ * single-instance claim, and a standalone that loses the socket race stays alive
191
+ * and quiescent while holding it (so launchd's KeepAlive doesn't restart-loop
192
+ * it), ready to take over if the hosted broker stops. Overloading it as the
193
+ * socket-ownership record made the standalone see a live holder and exit
194
+ * immediately — the exact restart loop that guard exists to prevent. This file
195
+ * answers a different question: which pid is serving the socket right now.
196
+ */
197
+ function ownerPath() {
198
+ return path.join(agentDir(), 'agent.owner');
199
+ }
167
200
  /**
168
201
  * Read the current broker capability token, or null if none is present. Clients
169
202
  * read it fresh per request and attach it to every non-ping command; a broker
@@ -309,17 +342,23 @@ export function handleAgentRequest(store, req, now = Date.now()) {
309
342
  // the broker is running pre-upgrade code and should be restarted.
310
343
  return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION, cliVersion: getCliVersion() };
311
344
  case 'get': {
312
- const key = scopedBundleKey(req.name, req.harness || 'cli');
313
- const e = store.get(key);
314
- if (!e || now >= e.expiresAt) {
315
- if (e)
316
- store.delete(key); // drop expired on read
317
- return { ok: true, cmd: 'get', hit: false };
345
+ // Walk own-harness global so a `--for` grant wins over a global one and
346
+ // an unscoped unlock serves every harness (bundleScopeChain).
347
+ for (const scope of bundleScopeChain(req.harness)) {
348
+ const key = scopedBundleKey(req.name, scope);
349
+ const e = store.get(key);
350
+ if (!e)
351
+ continue;
352
+ if (now >= e.expiresAt) {
353
+ store.delete(key);
354
+ continue;
355
+ } // drop expired on read
356
+ return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env };
318
357
  }
319
- return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env };
358
+ return { ok: true, cmd: 'get', hit: false };
320
359
  }
321
360
  case 'load':
322
- const harness = req.harness || 'cli';
361
+ const harness = req.harness || GLOBAL_HARNESS;
323
362
  store.set(scopedBundleKey(req.name, harness), { bundle: req.bundle, env: req.env, expiresAt: now + req.ttlMs, harness });
324
363
  return { ok: true, cmd: 'load' };
325
364
  case 'lock': {
@@ -413,11 +452,45 @@ export function makeConnectionHandler(handle, token) {
413
452
  conn.on('error', () => { });
414
453
  };
415
454
  }
455
+ /**
456
+ * Whether a live process still owns the broker pid file.
457
+ *
458
+ * A single missed `agentPing` is NOT proof the owner is dead — the broker is
459
+ * single-threaded, so a big `get` or the rehydrate at startup can blow the
460
+ * 700ms ping budget while the process is perfectly healthy. Treating that as
461
+ * death let a starting broker unlink a live socket and rebind, leaving the old
462
+ * process alive holding every unlocked bundle in RAM that no client could reach
463
+ * any more (observed live: two brokers, one socket path, two kernel sockets).
464
+ * The pid file is the second, independent liveness signal that makes the reclaim
465
+ * safe.
466
+ */
467
+ /** Drop our ownership record, but only if it is still ours — never clobber a
468
+ * successor that already claimed the socket. */
469
+ export function releaseBrokerPid() {
470
+ try {
471
+ if (parseInt(fs.readFileSync(ownerPath(), 'utf-8').trim(), 10) === process.pid) {
472
+ fs.unlinkSync(ownerPath());
473
+ }
474
+ }
475
+ catch { /* absent or unreadable — nothing to release */ }
476
+ }
477
+ export function brokerPidAlive() {
478
+ try {
479
+ const holder = parseInt(fs.readFileSync(ownerPath(), 'utf-8').trim(), 10);
480
+ return !isNaN(holder) && holder !== process.pid && isAlive(holder);
481
+ }
482
+ catch {
483
+ return false; // no pid file → nothing claims ownership
484
+ }
485
+ }
416
486
  /**
417
487
  * Bind the shared broker socket without stealing it from another live owner.
418
488
  * Both the standalone service and daemon-hosted broker use this single path so
419
489
  * either startup order is safe: a reachable owner wins, while an unreachable
420
490
  * stale socket is reclaimed once.
491
+ *
492
+ * "Unreachable" is established with retries plus a pid-file liveness check, never
493
+ * a single ping — see {@link brokerPidAlive}.
421
494
  */
422
495
  async function bindBrokerSocket(sock, onConnection) {
423
496
  const listenOnce = () => new Promise((resolve, reject) => {
@@ -441,14 +514,38 @@ async function bindBrokerSocket(sock, onConnection) {
441
514
  writeAgentToken();
442
515
  }
443
516
  catch { /* dir 0700 gates the socket regardless */ }
517
+ // Record ownership at the same moment, for the same reason. The
518
+ // daemon-hosted broker deliberately skips the O_EXCL pid-file GUARD (the
519
+ // daemon owns its instance), but without an ownership RECORD
520
+ // brokerPidAlive() has nothing to read — so a later starter saw an
521
+ // ownerless socket and reclaimed the live hosted broker anyway, which is
522
+ // the primary configuration and the one that actually orphaned here.
523
+ // Writing it from the confirmed owner covers both broker flavours.
524
+ try {
525
+ fs.writeFileSync(ownerPath(), String(process.pid));
526
+ }
527
+ catch { /* dir 0700 gates it */ }
444
528
  resolve(server);
445
529
  });
446
530
  });
447
531
  let bound = await listenOnce();
448
532
  if (bound !== 'inuse')
449
533
  return bound;
450
- if ((await agentPing()).reachable)
451
- return null;
534
+ // Give a busy owner several chances before concluding it is gone. One slow
535
+ // ping used to be enough to evict a healthy broker and orphan its RAM.
536
+ for (let attempt = 0; attempt < BIND_PROBE_ATTEMPTS; attempt++) {
537
+ if ((await agentPing()).reachable)
538
+ return null;
539
+ if (attempt < BIND_PROBE_ATTEMPTS - 1)
540
+ await delay(BIND_PROBE_INTERVAL_MS);
541
+ }
542
+ // Silent but alive is still alive: refuse to steal the socket from a process
543
+ // that still owns the pid file, and let the caller surface the wedge instead
544
+ // of quietly starting a second broker on the same path.
545
+ if (brokerPidAlive()) {
546
+ throw new Error(`Secrets broker socket is held by a live process that is not answering: ${sock}. ` +
547
+ `Run 'agents secrets lock --all' then retry, or stop the stuck broker.`);
548
+ }
452
549
  try {
453
550
  fs.unlinkSync(sock);
454
551
  }
@@ -625,6 +722,7 @@ export async function runSecretsAgent(opts = {}) {
625
722
  fs.unlinkSync(sock);
626
723
  }
627
724
  catch { /* gone */ }
725
+ releaseBrokerPid();
628
726
  releasePid();
629
727
  };
630
728
  sweepTimer = setInterval(sweep, SWEEP_INTERVAL_MS);
@@ -734,6 +832,7 @@ export async function startHostedBroker() {
734
832
  fs.unlinkSync(sock);
735
833
  }
736
834
  catch { /* gone */ }
835
+ releaseBrokerPid();
737
836
  },
738
837
  };
739
838
  }
@@ -834,7 +933,7 @@ function syncClient(sub, timeout) {
834
933
  * null if the agent isn't running / doesn't hold this bundle / anything fails
835
934
  * (soft — caller falls through to the real keychain). macOS only.
836
935
  */
837
- export function agentGetSync(name, harness = 'cli') {
936
+ export function agentGetSync(name, harness = GLOBAL_HARNESS) {
838
937
  if (!agentSocketExists())
839
938
  return null;
840
939
  const r = syncClient([SYNC_GET_CMD, name, harness], SYNC_GET_TIMEOUT_MS);
@@ -914,7 +1013,7 @@ export function agentEvictSync(name) {
914
1013
  // so a cache hit never runs checkForUpdates() or forks a detached sync.
915
1014
  /** Body of `__secrets-get <name>`. Prints `{bundle, env}` as JSON on a
916
1015
  * cache hit. Exit 0 = hit, 3 = miss or broker down. */
917
- export async function runAgentGetSync(name, harness = 'cli') {
1016
+ export async function runAgentGetSync(name, harness = GLOBAL_HARNESS) {
918
1017
  const r = await request({ cmd: 'get', name, harness }, SOCKET_GET_TIMEOUT_MS);
919
1018
  if (r?.ok === true && r.cmd === 'get' && r.hit) {
920
1019
  // Trailing newline: the parent reads the LAST line (see lastLine), so the
@@ -1066,7 +1165,7 @@ export function clampHoldMs(v) {
1066
1165
  * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
1067
1166
  * than a tight inline retry loop. Best-effort; never throws. macOS only.
1068
1167
  */
1069
- export function agentAutoLoadSync(name, bundle, env, ttlMs, harness = 'cli') {
1168
+ export function agentAutoLoadSync(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS) {
1070
1169
  if (!onDarwin())
1071
1170
  return;
1072
1171
  const payload = JSON.stringify({ name, bundle, env, ttlMs, harness });
@@ -1136,12 +1235,12 @@ export async function runAgentLoadFromStdin() {
1136
1235
  process.exitCode = 1; // broker couldn't be brought up — did NOT load
1137
1236
  return;
1138
1237
  }
1139
- const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS, payload.harness ?? 'cli');
1238
+ const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS, payload.harness ?? GLOBAL_HARNESS);
1140
1239
  if (!loaded)
1141
1240
  process.exitCode = 1; // transport failed — did NOT load
1142
1241
  }
1143
1242
  /** Store a resolved bundle in the broker. Returns false on transport failure. */
1144
- export async function agentLoad(name, bundle, env, ttlMs, harness = 'cli') {
1243
+ export async function agentLoad(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS) {
1145
1244
  const r = await request({ cmd: 'load', name, bundle, env, ttlMs, harness });
1146
1245
  return r?.ok === true && r.cmd === 'load';
1147
1246
  }
@@ -1236,6 +1335,12 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
1236
1335
  process.kill(stalePid, 'SIGTERM');
1237
1336
  }
1238
1337
  catch { /* already dead */ }
1338
+ // Wait for it to actually go before clearing its socket and ownership
1339
+ // record. Unlinking them under a live broker is what leaves an orphan
1340
+ // holding every unlocked bundle in RAM with no way to reach it — and it also
1341
+ // destroys the very record brokerPidAlive() reads, so the successor sees an
1342
+ // ownerless socket and reclaims it regardless.
1343
+ waitForExit(stalePid, BROKER_STOP_GRACE_MS);
1239
1344
  }
1240
1345
  try {
1241
1346
  fs.unlinkSync(socketPath());
@@ -1245,6 +1350,10 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
1245
1350
  fs.unlinkSync(pidPath());
1246
1351
  }
1247
1352
  catch { /* gone */ }
1353
+ try {
1354
+ fs.unlinkSync(ownerPath());
1355
+ }
1356
+ catch { /* gone */ }
1248
1357
  const { cmd, args } = brokerSpawn();
1249
1358
  spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
1250
1359
  const deadline = Date.now() + timeoutMs;
@@ -1274,6 +1383,7 @@ async function teardownStaleBroker() {
1274
1383
  process.kill(pid, 'SIGTERM');
1275
1384
  }
1276
1385
  catch { /* gone */ }
1386
+ waitForExit(pid, BROKER_STOP_GRACE_MS); // same reason as ensureAgentRunning
1277
1387
  }
1278
1388
  try {
1279
1389
  fs.unlinkSync(socketPath());
@@ -1283,4 +1393,8 @@ async function teardownStaleBroker() {
1283
1393
  fs.unlinkSync(pidPath());
1284
1394
  }
1285
1395
  catch { /* gone */ }
1396
+ try {
1397
+ fs.unlinkSync(ownerPath());
1398
+ }
1399
+ catch { /* gone */ }
1286
1400
  }
@@ -0,0 +1,46 @@
1
+ export type SecretAuditEvent = 'secrets.get' | 'secrets.unlocked';
2
+ export interface SecretAuditParams {
3
+ /** Which audit event this is — a read (`secrets.get`) or an unlock grant. */
4
+ event: SecretAuditEvent;
5
+ /** Bundle name (never a value). */
6
+ bundle?: string;
7
+ /** Raw keychain item/service name for `secrets get <item>` (never a value). */
8
+ item?: string;
9
+ /** Free-form operation/caller label, e.g. 'export to shell', 'unlock'. */
10
+ operation?: string;
11
+ /**
12
+ * Where the read/grant originated: agent | session | reveal | raw-item |
13
+ * remote | sync-push | broker | broker+durable.
14
+ */
15
+ source?: string;
16
+ status?: 'success' | 'error';
17
+ /** Env/key NAMES exposed — names only, never values. */
18
+ keys?: string[];
19
+ keyCount?: number;
20
+ /** Subset of `keys` backed by the OS keychain — names only. */
21
+ keychainKeys?: string[];
22
+ /** Count of resolved values by kind (literal / keychain / env / ...). */
23
+ kindCounts?: Record<string, number>;
24
+ /**
25
+ * Resolving agent/harness identity the access was scoped to (`*` = a global
26
+ * grant readable by every harness). Falls back to `$AGENTS_AGENT_NAME`.
27
+ */
28
+ agent?: string;
29
+ /** Remote host the value was pulled from, when applicable. */
30
+ host?: string;
31
+ /** Grant lifetime in ms (`secrets.unlocked` only). */
32
+ ttlMs?: number;
33
+ error?: string;
34
+ }
35
+ /**
36
+ * The agent/harness identity to attribute a secret access to. Explicit callers
37
+ * (the bundle reader knows the scope it resolved under) win; otherwise fall back
38
+ * to the ambient `$AGENTS_AGENT_NAME` set on every agent launch.
39
+ */
40
+ export declare function resolveAuditAgent(explicit?: string): string | undefined;
41
+ /**
42
+ * Emit one value-free secret audit event to the raw/operational event stream and
43
+ * the persisted audit log. Only ever pass metadata — the secret value must never
44
+ * reach this function.
45
+ */
46
+ export declare function emitSecretAudit(p: SecretAuditParams): void;