@phnx-labs/agents-cli 1.22.83 → 1.22.85

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 (291) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +80 -53
  3. package/dist/bootstrap.d.ts +7 -6
  4. package/dist/bootstrap.js +20 -27
  5. package/dist/cli/command-registry.js +5 -1
  6. package/dist/commands/accounts.d.ts +6 -13
  7. package/dist/commands/accounts.js +330 -143
  8. package/dist/commands/apply.js +1 -1
  9. package/dist/commands/auth-mint.d.ts +7 -3
  10. package/dist/commands/auth-mint.js +21 -9
  11. package/dist/commands/auth.js +2 -3
  12. package/dist/commands/browser.js +10 -10
  13. package/dist/commands/daemon.d.ts +7 -3
  14. package/dist/commands/daemon.js +40 -25
  15. package/dist/commands/doctor.js +28 -2
  16. package/dist/commands/exec.js +87 -39
  17. package/dist/commands/fleet-capture.js +21 -2
  18. package/dist/commands/harness-wizard.js +2 -2
  19. package/dist/commands/lease.js +7 -12
  20. package/dist/commands/profiles.d.ts +2 -2
  21. package/dist/commands/profiles.js +20 -13
  22. package/dist/commands/repo.js +3 -3
  23. package/dist/commands/run-account-picker.d.ts +3 -3
  24. package/dist/commands/run-account-picker.js +29 -30
  25. package/dist/commands/secrets-passthrough.d.ts +20 -0
  26. package/dist/commands/secrets-passthrough.js +43 -0
  27. package/dist/commands/sessions-trace.d.ts +11 -0
  28. package/dist/commands/sessions-trace.js +71 -0
  29. package/dist/commands/setup-accounts.d.ts +3 -2
  30. package/dist/commands/setup-accounts.js +4 -4
  31. package/dist/commands/setup-secrets.d.ts +16 -21
  32. package/dist/commands/setup-secrets.js +43 -224
  33. package/dist/commands/ssh.js +2 -2
  34. package/dist/commands/sync.js +1 -1
  35. package/dist/commands/update.d.ts +10 -0
  36. package/dist/commands/update.js +43 -40
  37. package/dist/commands/versions.d.ts +11 -11
  38. package/dist/commands/versions.js +131 -83
  39. package/dist/commands/view.d.ts +1 -9
  40. package/dist/commands/view.js +41 -55
  41. package/dist/commands/webhook.js +5 -5
  42. package/dist/commands/workflows.js +2 -1
  43. package/dist/index.d.ts +12 -12
  44. package/dist/index.js +13 -42
  45. package/dist/lib/account-capabilities.d.ts +1 -1
  46. package/dist/lib/account-capabilities.js +1 -1
  47. package/dist/lib/account-catalog.d.ts +121 -6
  48. package/dist/lib/account-catalog.js +421 -7
  49. package/dist/lib/account-registry.d.ts +8 -30
  50. package/dist/lib/account-registry.js +36 -79
  51. package/dist/lib/account-schema.d.ts +1 -1
  52. package/dist/lib/account-schema.js +1 -1
  53. package/dist/lib/accounting/account-pool-collect.js +16 -4
  54. package/dist/lib/accounting/rotate.d.ts +20 -7
  55. package/dist/lib/accounting/rotate.js +89 -13
  56. package/dist/lib/accounting/usage.js +11 -16
  57. package/dist/lib/accounts/add.d.ts +138 -0
  58. package/dist/lib/accounts/add.js +651 -0
  59. package/dist/lib/accounts/migrate.d.ts +117 -0
  60. package/dist/lib/accounts/migrate.js +536 -0
  61. package/dist/lib/accounts/slots.d.ts +13 -0
  62. package/dist/lib/accounts/slots.js +100 -0
  63. package/dist/lib/agent-spec/agents.d.ts +16 -0
  64. package/dist/lib/agent-spec/agents.js +32 -4
  65. package/dist/lib/app-bundle-install.js +5 -4
  66. package/dist/lib/auth-health.d.ts +3 -0
  67. package/dist/lib/auth-health.js +11 -0
  68. package/dist/lib/auth-mint.d.ts +52 -16
  69. package/dist/lib/auth-mint.js +117 -39
  70. package/dist/lib/browser/chrome.d.ts +1 -1
  71. package/dist/lib/browser/chrome.js +5 -5
  72. package/dist/lib/byok-usage.js +3 -3
  73. package/dist/lib/claude-account-token.d.ts +47 -3
  74. package/dist/lib/claude-account-token.js +172 -50
  75. package/dist/lib/cloud/antigravity.js +4 -4
  76. package/dist/lib/cloud/cursor.js +4 -4
  77. package/dist/lib/crabbox/cli.d.ts +1 -1
  78. package/dist/lib/crabbox/cli.js +6 -6
  79. package/dist/lib/crabbox/runtimes.d.ts +3 -3
  80. package/dist/lib/crabbox/runtimes.js +5 -5
  81. package/dist/lib/daemon/account-state-daemon-service.d.ts +37 -1
  82. package/dist/lib/daemon/account-state-daemon-service.js +199 -3
  83. package/dist/lib/daemon/auth-sync-service.js +28 -1
  84. package/dist/lib/daemon/daemon.d.ts +6 -16
  85. package/dist/lib/daemon/daemon.js +52 -97
  86. package/dist/lib/daemon/daemon.test-fixture.d.ts +7 -9
  87. package/dist/lib/daemon/daemon.test-fixture.js +22 -40
  88. package/dist/lib/daemon/harness-update-service.d.ts +1 -1
  89. package/dist/lib/daemon/harness-update-service.js +1 -1
  90. package/dist/lib/daemon/runner.d.ts +15 -3
  91. package/dist/lib/daemon/runner.js +49 -24
  92. package/dist/lib/daemon-health.d.ts +1 -2
  93. package/dist/lib/daemon-health.js +7 -6
  94. package/dist/lib/daemon-services.d.ts +1 -1
  95. package/dist/lib/daemon-services.js +1 -11
  96. package/dist/lib/daemon-ticks.js +23 -2
  97. package/dist/lib/daemon-webhooks.d.ts +8 -7
  98. package/dist/lib/daemon-webhooks.js +10 -9
  99. package/dist/lib/device-config.js +1 -1
  100. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  101. package/dist/lib/devices/harness-inventory.d.ts +39 -0
  102. package/dist/lib/devices/harness-inventory.js +126 -4
  103. package/dist/lib/doctor-diff.js +2 -1
  104. package/dist/lib/exec-account-home.d.ts +38 -0
  105. package/dist/lib/exec-account-home.js +164 -0
  106. package/dist/lib/exec.d.ts +30 -0
  107. package/dist/lib/exec.js +101 -31
  108. package/dist/lib/feed/activity-stream.d.ts +60 -0
  109. package/dist/lib/feed/activity-stream.js +271 -0
  110. package/dist/lib/feed/activity.d.ts +7 -0
  111. package/dist/lib/feed/activity.js +10 -4
  112. package/dist/lib/feed/watch.d.ts +9 -3
  113. package/dist/lib/feed/watch.js +72 -36
  114. package/dist/lib/fleet/apply.d.ts +2 -2
  115. package/dist/lib/fleet/apply.js +4 -4
  116. package/dist/lib/fleet/auth-sync.js +4 -3
  117. package/dist/lib/fleet-shared-repo-sync.js +6 -9
  118. package/dist/lib/fleet-shared-state.d.ts +6 -0
  119. package/dist/lib/harness/adapter.d.ts +7 -7
  120. package/dist/lib/harness/adapter.js +10 -6
  121. package/dist/lib/harness/adapters/grok.js +8 -3
  122. package/dist/lib/harness/adapters/muse.js +1 -1
  123. package/dist/lib/harness/adapters/opencode.js +12 -3
  124. package/dist/lib/harness-auth-capabilities.d.ts +61 -0
  125. package/dist/lib/harness-auth-capabilities.js +52 -0
  126. package/dist/lib/helper-versions.d.ts +6 -4
  127. package/dist/lib/helper-versions.js +5 -4
  128. package/dist/lib/hosts/credential-transport.d.ts +10 -0
  129. package/dist/lib/hosts/credential-transport.js +37 -0
  130. package/dist/lib/hosts/dispatch.d.ts +7 -0
  131. package/dist/lib/hosts/dispatch.js +13 -6
  132. package/dist/lib/identity/client.d.ts +3 -3
  133. package/dist/lib/identity/client.js +3 -3
  134. package/dist/lib/installations/index.d.ts +1 -1
  135. package/dist/lib/installations/index.js +1 -1
  136. package/dist/lib/installations/migrate.js +9 -0
  137. package/dist/lib/installations/resolve.js +1 -1
  138. package/dist/lib/installations/shims.d.ts +15 -0
  139. package/dist/lib/installations/shims.js +69 -0
  140. package/dist/lib/installations/store.d.ts +43 -0
  141. package/dist/lib/installations/store.js +75 -7
  142. package/dist/lib/installations/versions.js +1 -1
  143. package/dist/lib/menubar/install-menubar.d.ts +3 -3
  144. package/dist/lib/menubar/install-menubar.js +3 -3
  145. package/dist/lib/native-accounts.d.ts +35 -0
  146. package/dist/lib/native-accounts.js +41 -0
  147. package/dist/lib/net-close.d.ts +11 -0
  148. package/dist/lib/net-close.js +25 -0
  149. package/dist/lib/openclaw-keychain.js +27 -1
  150. package/dist/lib/profiles.js +22 -9
  151. package/dist/lib/project-resources.js +1 -1
  152. package/dist/lib/reserved-stores.d.ts +53 -0
  153. package/dist/lib/reserved-stores.js +120 -0
  154. package/dist/lib/secrets-client.d.ts +191 -0
  155. package/dist/lib/secrets-client.js +710 -0
  156. package/dist/lib/secrets-policy.d.ts +222 -0
  157. package/dist/lib/secrets-policy.js +483 -0
  158. package/dist/lib/secrets-types.d.ts +188 -0
  159. package/dist/lib/secrets-types.js +24 -0
  160. package/dist/lib/service-manifest.js +3 -4
  161. package/dist/lib/session/active.d.ts +38 -4
  162. package/dist/lib/session/active.js +36 -4
  163. package/dist/lib/session/bash-command.js +10 -0
  164. package/dist/lib/session/db.d.ts +65 -2
  165. package/dist/lib/session/db.js +178 -18
  166. package/dist/lib/session/mirror.d.ts +5 -0
  167. package/dist/lib/session/mirror.js +166 -0
  168. package/dist/lib/session/parse.d.ts +49 -0
  169. package/dist/lib/session/parse.js +324 -30
  170. package/dist/lib/session/prompt.d.ts +93 -2
  171. package/dist/lib/session/prompt.js +266 -15
  172. package/dist/lib/session/remote/peer-stream.d.ts +47 -0
  173. package/dist/lib/session/remote/peer-stream.js +142 -0
  174. package/dist/lib/session/remote/watch.d.ts +1 -1
  175. package/dist/lib/session/remote/watch.js +41 -42
  176. package/dist/lib/session/session-cache.d.ts +9 -0
  177. package/dist/lib/session/session-cache.js +40 -2
  178. package/dist/lib/session/sync/config.js +2 -2
  179. package/dist/lib/session/timeline-pass.d.ts +129 -0
  180. package/dist/lib/session/timeline-pass.js +323 -0
  181. package/dist/lib/session/timeline.d.ts +182 -0
  182. package/dist/lib/session/timeline.js +636 -0
  183. package/dist/lib/session/types.d.ts +155 -1
  184. package/dist/lib/sha256-asset.d.ts +11 -16
  185. package/dist/lib/sha256-asset.js +11 -16
  186. package/dist/lib/share/config.js +14 -5
  187. package/dist/lib/signin-badge.d.ts +26 -0
  188. package/dist/lib/signin-badge.js +42 -0
  189. package/dist/lib/staleness/detectors/workflows.js +3 -105
  190. package/dist/lib/staleness/writers/workflows.js +2 -1
  191. package/dist/lib/state.d.ts +7 -4
  192. package/dist/lib/state.js +18 -7
  193. package/dist/lib/summarizer/pass.d.ts +2 -0
  194. package/dist/lib/summarizer/pass.js +14 -1
  195. package/dist/lib/summarizer/summarize.d.ts +7 -0
  196. package/dist/lib/summarizer/summarize.js +3 -0
  197. package/dist/lib/sync-umbrella.js +2 -2
  198. package/dist/lib/teams/agents.js +1 -1
  199. package/dist/lib/types.d.ts +74 -33
  200. package/dist/lib/view-types.d.ts +6 -3
  201. package/dist/lib/workflows-registry.d.ts +71 -0
  202. package/dist/lib/workflows-registry.js +280 -0
  203. package/dist/lib/workflows.d.ts +21 -21
  204. package/dist/lib/workflows.js +12 -327
  205. package/package.json +2 -3
  206. package/scripts/postinstall.js +4 -29
  207. package/dist/commands/secrets-import.d.ts +0 -18
  208. package/dist/commands/secrets-import.js +0 -74
  209. package/dist/commands/secrets-migrate.d.ts +0 -25
  210. package/dist/commands/secrets-migrate.js +0 -334
  211. package/dist/commands/secrets-rotate-passphrase.d.ts +0 -17
  212. package/dist/commands/secrets-rotate-passphrase.js +0 -96
  213. package/dist/commands/secrets-sync.d.ts +0 -11
  214. package/dist/commands/secrets-sync.js +0 -153
  215. package/dist/commands/secrets-vault.d.ts +0 -10
  216. package/dist/commands/secrets-vault.js +0 -130
  217. package/dist/commands/secrets.d.ts +0 -212
  218. package/dist/commands/secrets.js +0 -3030
  219. package/dist/lib/accounts/connect.d.ts +0 -170
  220. package/dist/lib/accounts/connect.js +0 -383
  221. package/dist/lib/daemon/keychain-reap-service.d.ts +0 -17
  222. package/dist/lib/daemon/keychain-reap-service.js +0 -32
  223. package/dist/lib/daemon/secrets-broker-service.d.ts +0 -21
  224. package/dist/lib/daemon/secrets-broker-service.js +0 -51
  225. package/dist/lib/secrets/agent.d.ts +0 -358
  226. package/dist/lib/secrets/agent.js +0 -1291
  227. package/dist/lib/secrets/audit.d.ts +0 -46
  228. package/dist/lib/secrets/audit.js +0 -101
  229. package/dist/lib/secrets/bundles.d.ts +0 -290
  230. package/dist/lib/secrets/bundles.js +0 -1547
  231. package/dist/lib/secrets/download-keychain.d.ts +0 -47
  232. package/dist/lib/secrets/download-keychain.js +0 -70
  233. package/dist/lib/secrets/drivers/rush.d.ts +0 -14
  234. package/dist/lib/secrets/drivers/rush.js +0 -90
  235. package/dist/lib/secrets/fallback.d.ts +0 -48
  236. package/dist/lib/secrets/fallback.js +0 -48
  237. package/dist/lib/secrets/filestore.d.ts +0 -222
  238. package/dist/lib/secrets/filestore.js +0 -1099
  239. package/dist/lib/secrets/headless.d.ts +0 -43
  240. package/dist/lib/secrets/headless.js +0 -56
  241. package/dist/lib/secrets/icloud-import.d.ts +0 -79
  242. package/dist/lib/secrets/icloud-import.js +0 -206
  243. package/dist/lib/secrets/index.d.ts +0 -451
  244. package/dist/lib/secrets/index.js +0 -1568
  245. package/dist/lib/secrets/install-helper.d.ts +0 -72
  246. package/dist/lib/secrets/install-helper.js +0 -245
  247. package/dist/lib/secrets/lease.d.ts +0 -25
  248. package/dist/lib/secrets/lease.js +0 -44
  249. package/dist/lib/secrets/linux.d.ts +0 -77
  250. package/dist/lib/secrets/linux.js +0 -393
  251. package/dist/lib/secrets/list-filter.d.ts +0 -109
  252. package/dist/lib/secrets/list-filter.js +0 -261
  253. package/dist/lib/secrets/mcp.d.ts +0 -93
  254. package/dist/lib/secrets/mcp.js +0 -211
  255. package/dist/lib/secrets/profiles.d.ts +0 -10
  256. package/dist/lib/secrets/profiles.js +0 -13
  257. package/dist/lib/secrets/push.d.ts +0 -133
  258. package/dist/lib/secrets/push.js +0 -273
  259. package/dist/lib/secrets/rc-hygiene.d.ts +0 -63
  260. package/dist/lib/secrets/rc-hygiene.js +0 -143
  261. package/dist/lib/secrets/read-backoff.d.ts +0 -27
  262. package/dist/lib/secrets/read-backoff.js +0 -64
  263. package/dist/lib/secrets/reaper.d.ts +0 -90
  264. package/dist/lib/secrets/reaper.js +0 -243
  265. package/dist/lib/secrets/remote.d.ts +0 -152
  266. package/dist/lib/secrets/remote.js +0 -344
  267. package/dist/lib/secrets/reserved-sync.d.ts +0 -66
  268. package/dist/lib/secrets/reserved-sync.js +0 -147
  269. package/dist/lib/secrets/scope.d.ts +0 -26
  270. package/dist/lib/secrets/scope.js +0 -29
  271. package/dist/lib/secrets/session-store.d.ts +0 -107
  272. package/dist/lib/secrets/session-store.js +0 -342
  273. package/dist/lib/secrets/sync-backend.d.ts +0 -48
  274. package/dist/lib/secrets/sync-backend.js +0 -13
  275. package/dist/lib/secrets/sync-commands.d.ts +0 -21
  276. package/dist/lib/secrets/sync-commands.js +0 -21
  277. package/dist/lib/secrets/sync.d.ts +0 -48
  278. package/dist/lib/secrets/sync.js +0 -237
  279. package/dist/lib/secrets/unlock-hints.d.ts +0 -27
  280. package/dist/lib/secrets/unlock-hints.js +0 -36
  281. package/dist/lib/secrets/usage-db.d.ts +0 -46
  282. package/dist/lib/secrets/usage-db.js +0 -96
  283. package/dist/lib/secrets/vault-age-helper.d.ts +0 -1
  284. package/dist/lib/secrets/vault-age-helper.js +0 -34
  285. package/dist/lib/secrets/vault.d.ts +0 -49
  286. package/dist/lib/secrets/vault.js +0 -399
  287. package/dist/lib/secrets/windows.d.ts +0 -81
  288. package/dist/lib/secrets/windows.js +0 -556
  289. package/scripts/install-helper.js +0 -97
  290. /package/dist/lib/{secrets/sync-passphrase.d.ts → sync-passphrase.d.ts} +0 -0
  291. /package/dist/lib/{secrets/sync-passphrase.js → sync-passphrase.js} +0 -0
@@ -1,1547 +0,0 @@
1
- /**
2
- * Secret bundles — named sets of environment variables backed by a secret store.
3
- * Metadata lives under `agents-cli.bundles.<name>`; values under
4
- * `agents-cli.secrets.<bundle>.<key>`. Backends: `keychain` (default),
5
- * `file` (headless/passphrase), and `vault` (age-encrypted, user-synced).
6
- */
7
- import * as fs from 'fs';
8
- import * as os from 'os';
9
- import * as path from 'path';
10
- import * as yaml from 'yaml';
11
- import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, isKeychainBackendOverridden, keychainServiceAlias, keychainUsesFileFallback, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
12
- import { fileStore } from './filestore.js';
13
- import { getVaultSession, vaultDeleteItem, vaultExists, vaultGetItems, vaultGetItem, vaultHasItem, vaultListItems, vaultSetItems, vaultSetItem, } from './vault.js';
14
- import { emit } from '../feed/events.js';
15
- import { emitSecretAudit } from './audit.js';
16
- import { readMeta, getHelpersDir } from '../state.js';
17
- import { assertNameActiveInResourceProfile, filterNamesForActiveResourceProfile } from '../resource-profiles.js';
18
- import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, agentEvictSync, secretsAgentAutoEnabled, secretsHoldMs, isSecretsBrokerEnabled } from './agent.js';
19
- import { GLOBAL_HARNESS } from './scope.js';
20
- import { resolveSession, deleteSession } from './session-store.js';
21
- import { createHash } from 'node:crypto';
22
- const keychainStore = {
23
- has: hasKeychainToken,
24
- get: getKeychainToken,
25
- getBatch: getKeychainTokens,
26
- set: setKeychainToken,
27
- setBatch: (items, opts) => {
28
- for (const [item, value] of items)
29
- setKeychainToken(item, value, opts);
30
- },
31
- delete: deleteKeychainToken,
32
- list: listKeychainItems,
33
- };
34
- // File store auto-provisions a machine-local 0600 key on every platform,
35
- // so `agents secrets` works headless without a passphrase. Override with
36
- // AGENTS_SECRETS_PASSPHRASE.
37
- const fileItemStore = {
38
- has: (item) => fileStore.has(item),
39
- get: (item) => fileStore.get(item),
40
- getBatch: (items) => fileStore.getBatch(items),
41
- set: (item, value) => fileStore.set(item, value),
42
- setBatch: (items) => {
43
- for (const [item, value] of items) {
44
- fileStore.set(item, value);
45
- }
46
- },
47
- delete: (item) => fileStore.delete(item),
48
- list: (prefix) => fileStore.list(prefix),
49
- };
50
- const vaultStore = {
51
- has: vaultHasItem,
52
- get: vaultGetItem,
53
- getBatch: vaultGetItems,
54
- set: (item, value) => vaultSetItem(item, value),
55
- setBatch: (items) => vaultSetItems(items),
56
- delete: vaultDeleteItem,
57
- list: vaultListItems,
58
- };
59
- let keychainAgentOnlyBypassForTest = false;
60
- /** Disable the broker-only guard for in-memory keychain tests. */
61
- export function setKeychainAgentOnlyBypassForTest(bypass) {
62
- keychainAgentOnlyBypassForTest = bypass;
63
- }
64
- function itemStore(backend) {
65
- if (backend === 'file')
66
- return fileItemStore;
67
- if (backend === 'vault')
68
- return vaultStore;
69
- return keychainStore;
70
- }
71
- /**
72
- * Discover a bundle's backend by location. File store is checked first (a plain
73
- * existence test, no passphrase); absent/locked vault falls back to keychain.
74
- */
75
- export function bundleBackend(name) {
76
- const item = BUNDLE_META_PREFIX + name;
77
- if (fileStore.has(item))
78
- return 'file';
79
- if (vaultExists() && getVaultSession().loggedIn) {
80
- try {
81
- if (vaultHasItem(item))
82
- return 'vault';
83
- }
84
- catch {
85
- // A vault problem should not hide a keychain/file bundle that already
86
- // resolved above; exact vault reads surface the decrypt/login error.
87
- }
88
- }
89
- return 'keychain';
90
- }
91
- function assertVaultBackendUsable(name) {
92
- if (getVaultSession().loggedIn)
93
- return;
94
- throw new Error(`Synced bundle '${name}' needs an active login. Run: agents secrets vault unlock`);
95
- }
96
- /** Allowed values for a secret's `type` metadata field. */
97
- export const SECRET_TYPES = [
98
- 'api-key',
99
- 'token',
100
- 'password',
101
- 'url',
102
- 'database-url',
103
- 'ssh-key',
104
- 'certificate',
105
- 'webhook',
106
- 'note',
107
- ];
108
- /** Throttle last_used writes so the keychain isn't touched on every injection. */
109
- const LAST_USED_THROTTLE_MS = 60_000;
110
- export const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_.]{0,48}$/i;
111
- export const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
112
- export const BUNDLE_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_-]+)?$/;
113
- export const BUNDLE_META_PREFIX = 'agents-cli.bundles.';
114
- const SECRETS_ITEM_PREFIX = 'agents-cli.secrets.';
115
- export const RESERVED_ENV_NAMES = new Set([
116
- 'PATH', 'HOME', 'USER', 'USERNAME', 'SHELL', 'PWD', 'OLDPWD',
117
- 'TERM', 'LANG', 'LC_ALL', 'DISPLAY', 'EDITOR', 'VISUAL',
118
- 'TMPDIR', 'TMP', 'TEMP', 'LOGNAME', 'UID', 'EUID', 'HOSTNAME',
119
- ]);
120
- /**
121
- * Reserved FILE-BACKED bundle for long-lived setup tokens. Must be file-backed;
122
- * a keychain/vault `auth` bundle is a misconfiguration and is ignored.
123
- */
124
- export const AUTH_BUNDLE_NAME = 'auth';
125
- export const AUTH_BUNDLE_BACKEND = 'file';
126
- export const RESERVED_BUNDLE_NAMES = new Set([AUTH_BUNDLE_NAME]);
127
- export function isReservedBundleName(name) {
128
- return RESERVED_BUNDLE_NAMES.has(name.trim().toLowerCase());
129
- }
130
- /** Thrown when a reserved bundle is written or resolved on the wrong backend. */
131
- export class ReservedBundleWrongBackendError extends Error {
132
- bundle;
133
- backend;
134
- constructor(bundle, backend) {
135
- super(`Bundle '${bundle}' is reserved for file-backed setup-tokens (headless, fleet-shareable). ` +
136
- `A ${backend}-backed '${bundle}' bundle is ignored by usage/probe instead of authenticating. ` +
137
- `Recreate it as file-backed: agents secrets delete ${bundle} --yes && agents secrets create ${bundle} --backend file`);
138
- this.name = 'ReservedBundleWrongBackendError';
139
- this.bundle = bundle;
140
- this.backend = backend;
141
- }
142
- }
143
- /** Fail loud when a reserved bundle is on the wrong backend. */
144
- export function assertReservedBundleBackend(name, backend) {
145
- if (!isReservedBundleName(name))
146
- return;
147
- if (backend !== AUTH_BUNDLE_BACKEND) {
148
- throw new ReservedBundleWrongBackendError(name, backend);
149
- }
150
- }
151
- /**
152
- * Check the reserved `auth` bundle. `ok` is true when absent or file-backed.
153
- *
154
- * Read-only status probe (`agents doctor`, `agents fleet apply`) — unlike the
155
- * destructive-write guards `bundleExists()`/`hasKeychainToken()` document
156
- * themselves as failing loud for, a diagnostic MUST NOT crash the whole
157
- * command because ONE optional finding could not reach the keychain (e.g. the
158
- * macOS Keychain helper source is unavailable — PHNX-3385). Treat an
159
- * unreachable keychain the same as "bundle absent": nothing to warn about.
160
- */
161
- export function inspectReservedAuthBundle() {
162
- let exists;
163
- try {
164
- exists = bundleExists(AUTH_BUNDLE_NAME);
165
- }
166
- catch {
167
- return { exists: false, backend: null, ok: true };
168
- }
169
- if (!exists) {
170
- return { exists: false, backend: null, ok: true };
171
- }
172
- const backend = bundleBackend(AUTH_BUNDLE_NAME);
173
- return { exists: true, backend, ok: backend === AUTH_BUNDLE_BACKEND };
174
- }
175
- /**
176
- * After a file-backed import, verify the keys actually decrypt. Import used to
177
- * report success for ciphertext sealed under a forwarded passphrase this process
178
- * does not hold.
179
- */
180
- export function assertFileBundleDecryptable(name, keys) {
181
- if (keys.length === 0)
182
- return;
183
- if (bundleBackend(name) !== 'file')
184
- return;
185
- let env;
186
- try {
187
- ({ env } = readAndResolveBundleEnv(name, { caller: 'import-verify', agentOnly: true, keyMode: 'storage' }));
188
- }
189
- catch (err) {
190
- throw new Error(`Imported '${name}' reported success but the file store could not decrypt it. ` +
191
- `${err.message} Typically because AGENTS_SECRETS_PASSPHRASE was forwarded ` +
192
- `and this process does not hold it. Re-import without that env var.`);
193
- }
194
- const missing = keys.filter((k) => {
195
- const v = env[k];
196
- return typeof v !== 'string' || v.length === 0;
197
- });
198
- if (missing.length === 0)
199
- return;
200
- throw new Error(`Imported '${name}' reported success but ${missing.length} key(s) are unreadable ` +
201
- `(${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}). ` +
202
- `The destination store could not decrypt them — typically because AGENTS_SECRETS_PASSPHRASE ` +
203
- `was forwarded and this process does not hold it. Re-import without that env var.`);
204
- }
205
- export function bundleToEnvPrefix(name) {
206
- return name.replace(/[-\.]/g, '_').toUpperCase();
207
- }
208
- export function isReservedEnvName(key) {
209
- return RESERVED_ENV_NAMES.has(key.toUpperCase());
210
- }
211
- export function bundleKeyToEnvKey(key) {
212
- const dot = key.indexOf('.');
213
- return dot === -1 ? key : key.slice(0, dot);
214
- }
215
- export function isLoaderOrInterpreterEnv(name) {
216
- const upper = name.toUpperCase();
217
- return upper.startsWith('LD_') ||
218
- upper.startsWith('DYLD_') ||
219
- [
220
- 'NODE_OPTIONS',
221
- 'PYTHONPATH',
222
- 'PYTHONSTARTUP',
223
- 'BASH_ENV',
224
- 'ENV',
225
- 'PERL5OPT',
226
- 'RUBYOPT',
227
- 'PROMPT_COMMAND',
228
- 'IFS',
229
- 'CDPATH',
230
- ].includes(upper);
231
- }
232
- export function sanitizeProcessEnv(env = process.env) {
233
- const out = {};
234
- for (const [k, v] of Object.entries(env)) {
235
- if (v === undefined)
236
- continue;
237
- if (isLoaderOrInterpreterEnv(k))
238
- continue;
239
- out[k] = v;
240
- }
241
- return out;
242
- }
243
- /** Validate a bundle name against the allowed pattern. Throws on invalid input. */
244
- export function validateBundleName(name) {
245
- if (!BUNDLE_NAME_PATTERN.test(name)) {
246
- throw new Error(`Invalid bundle name '${name}'. Use letters, digits, dash, underscore, dot (max 48 chars).`);
247
- }
248
- }
249
- export function validateEnvKey(key) {
250
- if (!BUNDLE_KEY_PATTERN.test(key)) {
251
- throw new Error(`Invalid bundle key '${key}'. Must match [A-Za-z_][A-Za-z0-9_]* with optional .account suffix.`);
252
- }
253
- const envKey = bundleKeyToEnvKey(key);
254
- if (isLoaderOrInterpreterEnv(envKey) || isReservedEnvName(envKey)) {
255
- throw new Error(`Env key "${key}" is reserved — cannot be used in a secrets bundle. Reserved keys include PATH, HOME, USER, and dynamic-loader/interpreter vars (LD_*, DYLD_*, NODE_OPTIONS, etc.).`);
256
- }
257
- }
258
- /** Assert that `t` is one of the known SECRET_TYPES. Throws with the allowed list otherwise. */
259
- export function validateSecretType(t) {
260
- if (!SECRET_TYPES.includes(t)) {
261
- throw new Error(`Invalid type '${t}'. One of: ${SECRET_TYPES.join(', ')}.`);
262
- }
263
- }
264
- /**
265
- * Validate a future `expires` date. Accepts strict 'YYYY-MM-DD'; end-of-day UTC
266
- * means "today" is past.
267
- */
268
- export function validateExpiresFutureDated(iso) {
269
- if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
270
- throw new Error(`Invalid --expires '${iso}'. Use YYYY-MM-DD.`);
271
- }
272
- const target = new Date(iso + 'T23:59:59Z');
273
- if (Number.isNaN(target.getTime()))
274
- throw new Error(`Invalid --expires date '${iso}'.`);
275
- if (target.getTime() <= Date.now()) {
276
- throw new Error(`--expires must be future-dated. Got '${iso}'.`);
277
- }
278
- }
279
- function bundleMetaItem(name) {
280
- return BUNDLE_META_PREFIX + name;
281
- }
282
- export function bundleExists(name) {
283
- validateBundleName(name);
284
- return itemStore(bundleBackend(name)).has(bundleMetaItem(name));
285
- }
286
- /**
287
- * Thrown for file-store ciphertext that will not decrypt (lost/rotated key or
288
- * tampered store). Narrow: temporarily-locked bundles are recoverable and must
289
- * not be collapsed here.
290
- */
291
- export class BundleUndecryptableError extends Error {
292
- constructor(message) {
293
- super(message);
294
- this.name = 'BundleUndecryptableError';
295
- }
296
- }
297
- /**
298
- * Read a bundle, returning null only when metadata is present but permanently
299
- * unreadable (`BundleUndecryptableError`). Other failures — including temporary
300
- * lockout — rethrow so a healthy bundle is never deleted by mistake.
301
- */
302
- export function readBundleIfDecryptable(name) {
303
- try {
304
- return readBundle(name);
305
- }
306
- catch (err) {
307
- if (err instanceof BundleUndecryptableError)
308
- return null;
309
- throw err;
310
- }
311
- }
312
- export function readBundle(name) {
313
- validateBundleName(name);
314
- const backend = bundleBackend(name);
315
- if (backend === 'vault')
316
- assertVaultBackendUsable(name);
317
- let json;
318
- try {
319
- // Metadata is no-ACL by contract; attest silentNoAcl so headless reads don't
320
- // trip the raw-read storm guard. A legacy ACL'd item may prompt once, then heals.
321
- json = backend === 'keychain'
322
- ? getKeychainToken(bundleMetaItem(name), { silentNoAcl: true })
323
- : itemStore(backend).get(bundleMetaItem(name));
324
- }
325
- catch (err) {
326
- // File-backed metadata that fails to decrypt is a wrong-passphrase error.
327
- if (backend === 'file' && fileStore.has(bundleMetaItem(name))) {
328
- throw new BundleUndecryptableError(`Bundle '${name}': failed to decrypt — wrong AGENTS_SECRETS_PASSPHRASE or tampered file store. (${err.message})`);
329
- }
330
- if (vaultExists() && !getVaultSession().loggedIn) {
331
- throw new Error(`Synced secrets are locked. Run: agents secrets vault unlock`);
332
- }
333
- // A present-but-unreadable keychain item must not report "not found".
334
- if (backend === 'keychain') {
335
- let present;
336
- try {
337
- present = hasKeychainToken(bundleMetaItem(name));
338
- }
339
- catch (probeErr) {
340
- // Keychain unreachable — fail loud rather than report false absence.
341
- throw new Error(`Secrets bundle '${name}': ${probeErr.message}`);
342
- }
343
- if (present) {
344
- throw new Error(`Secrets bundle '${name}' is present but its metadata could not be read — the keychain is locked. ` +
345
- `Unlock it (log in, or reboot then log in) and retry. (${err.message})`);
346
- }
347
- }
348
- throw new Error(`Secrets bundle '${name}' not found.`);
349
- }
350
- let parsed;
351
- try {
352
- parsed = JSON.parse(json);
353
- }
354
- catch {
355
- throw new Error(`Bundle '${name}' is malformed.`);
356
- }
357
- if (!parsed || typeof parsed !== 'object') {
358
- throw new Error(`Bundle '${name}' is malformed.`);
359
- }
360
- // Drop unknown fields; `backend` is authoritative from location discovery.
361
- const bundle = {
362
- name,
363
- description: parsed.description,
364
- allow_exec: Boolean(parsed.allow_exec),
365
- // Absent ⇒ keychain; only set when non-keychain so a keychain bundle
366
- // round-trips byte-for-byte.
367
- backend: backend === 'keychain' ? undefined : backend,
368
- // Legacy wire key: the policy is persisted under `tier` (`session` == `hold`).
369
- policy: parsePolicy(parsed.tier),
370
- vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
371
- };
372
- if (typeof parsed.created_at === 'string')
373
- bundle.created_at = parsed.created_at;
374
- if (typeof parsed.updated_at === 'string')
375
- bundle.updated_at = parsed.updated_at;
376
- if (typeof parsed.last_used === 'string')
377
- bundle.last_used = parsed.last_used;
378
- if (parsed.meta && typeof parsed.meta === 'object') {
379
- bundle.meta = parsed.meta;
380
- }
381
- for (const key of Object.keys(bundle.vars)) {
382
- validateEnvKey(key);
383
- }
384
- return bundle;
385
- }
386
- /** Normalize the persisted `tier` token to the current policy vocabulary. */
387
- function parsePolicy(raw) {
388
- if (raw === 'hold' || raw === 'daily' || raw === 'session')
389
- return 'hold';
390
- if (raw === 'always' || raw === 'biometry')
391
- return 'always';
392
- if (raw === 'never' || raw === 'none')
393
- return 'never';
394
- return undefined;
395
- }
396
- /** Default policy for bundles without an explicit one (`secrets.policy`). */
397
- export function secretsDefaultPolicy() {
398
- try {
399
- return readMeta().secrets?.policy === 'always' ? 'always' : 'hold';
400
- }
401
- catch {
402
- return 'hold';
403
- }
404
- }
405
- /** Effective prompt policy of a bundle. */
406
- export function bundlePolicy(bundle) {
407
- return bundle.policy ?? secretsDefaultPolicy();
408
- }
409
- /**
410
- * Whether a bundle write should evict the broker-held copy. Exported for tests.
411
- * Skips when the writer opted out, the broker is disabled, or a test backend is
412
- * active (so tests don't evict the user's real unlocks).
413
- */
414
- export function shouldEvictAfterBundleWrite(skipRequested, noAgentEnv, backendOverridden) {
415
- if (skipRequested)
416
- return false;
417
- if (noAgentEnv === '1')
418
- return false;
419
- if (backendOverridden)
420
- return false;
421
- return true;
422
- }
423
- function prepareBundleWrite(bundle) {
424
- validateBundleName(bundle.name);
425
- const backend = bundle.backend ?? 'keychain';
426
- assertReservedBundleBackend(bundle.name, backend);
427
- if (backend === 'vault')
428
- assertVaultBackendUsable(bundle.name);
429
- for (const key of Object.keys(bundle.vars)) {
430
- validateEnvKey(key);
431
- }
432
- // Strip empty meta entries so the JSON stays tidy.
433
- let meta;
434
- if (bundle.meta) {
435
- for (const [key, m] of Object.entries(bundle.meta)) {
436
- const cleaned = {};
437
- if (m.type)
438
- cleaned.type = m.type;
439
- if (m.expires)
440
- cleaned.expires = m.expires;
441
- if (m.note)
442
- cleaned.note = m.note;
443
- if (Object.keys(cleaned).length > 0) {
444
- if (!meta)
445
- meta = {};
446
- meta[key] = cleaned;
447
- }
448
- }
449
- }
450
- // created_at is sticky; updated_at always advances.
451
- const now = new Date().toISOString();
452
- if (!bundle.created_at)
453
- bundle.created_at = now;
454
- bundle.updated_at = now;
455
- const payload = {
456
- // Persist the display name so hashed keychain items remain listable.
457
- name: bundle.name,
458
- description: bundle.description,
459
- allow_exec: bundle.allow_exec ? true : undefined,
460
- backend: backend === 'keychain' ? undefined : backend,
461
- // Legacy wire token for cross-version sync.
462
- tier: bundle.policy === 'hold' ? 'session'
463
- : bundle.policy === 'always' ? 'biometry'
464
- : bundle.policy === 'never' ? 'none'
465
- : undefined,
466
- created_at: bundle.created_at,
467
- updated_at: bundle.updated_at,
468
- last_used: bundle.last_used,
469
- vars: bundle.vars,
470
- meta,
471
- };
472
- return {
473
- backend,
474
- metadataItem: bundleMetaItem(bundle.name),
475
- metadataJson: JSON.stringify(payload),
476
- };
477
- }
478
- function finishBundleWrite(bundle, opts) {
479
- emit('secrets.set', { module: 'secrets', bundle: bundle.name });
480
- // Evict the broker-held snapshot and durable session so the next read resolves fresh.
481
- if (shouldEvictAfterBundleWrite(Boolean(opts.skipBrokerEviction), process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
482
- agentEvictSync(bundle.name);
483
- deleteSession(bundle.name);
484
- }
485
- }
486
- export function writeBundle(bundle, opts = {}) {
487
- const prepared = prepareBundleWrite(bundle);
488
- // Metadata is non-sensitive by contract and stored no-ACL so `secrets list`
489
- // can enumerate without Touch ID. A pinned helper without the no-ACL command
490
- // fails loudly rather than silently landing an ACL'd item.
491
- itemStore(prepared.backend).set(prepared.metadataItem, prepared.metadataJson, { noAcl: true });
492
- if (prepared.backend === 'keychain')
493
- addBundleToMetaIndex(bundle.name);
494
- finishBundleWrite(bundle, opts);
495
- }
496
- export function writeBundleWithItems(bundle, items, opts = {}) {
497
- const prepared = prepareBundleWrite(bundle);
498
- const store = itemStore(prepared.backend);
499
- if (prepared.backend === 'keychain') {
500
- // Keychain values carry the policy ACL; metadata is always no-ACL. They cannot
501
- // share one batch flag, and metadata is written last so partial writes read as
502
- // "no bundle yet".
503
- if (items.size > 0) {
504
- store.setBatch(new Map(items), { noAcl: bundle.policy === 'never' });
505
- }
506
- store.set(prepared.metadataItem, prepared.metadataJson, { noAcl: true });
507
- addBundleToMetaIndex(bundle.name);
508
- }
509
- else {
510
- // File/vault have no ACL; one batched write is cheaper.
511
- const batch = new Map(items);
512
- batch.set(prepared.metadataItem, prepared.metadataJson);
513
- store.setBatch(batch, { noAcl: bundle.policy === 'never' });
514
- }
515
- finishBundleWrite(bundle, opts);
516
- }
517
- export function deleteBundle(name) {
518
- validateBundleName(name);
519
- const backend = bundleBackend(name);
520
- const deleted = itemStore(backend).delete(bundleMetaItem(name));
521
- if (deleted) {
522
- if (backend === 'keychain')
523
- removeBundleFromMetaIndex(name);
524
- emit('secrets.delete', { module: 'secrets', bundle: name });
525
- if (shouldEvictAfterBundleWrite(false, process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
526
- agentEvictSync(name);
527
- deleteSession(name); // a deleted bundle must not be rehydratable
528
- }
529
- }
530
- return deleted;
531
- }
532
- /**
533
- * Parse a stored metadata JSON blob into a SecretsBundle, skipping malformed
534
- * bundles. `backend` is authoritative. `nameHint` is cleartext (Linux/file/
535
- * legacy); hashed keychain items recover the name from the persisted payload.
536
- */
537
- function parseBundleMeta(nameHint, json, backend) {
538
- let parsed;
539
- try {
540
- parsed = JSON.parse(json);
541
- }
542
- catch {
543
- return null;
544
- }
545
- if (!parsed || typeof parsed !== 'object')
546
- return null;
547
- const name = nameHint ?? (typeof parsed.name === 'string' ? parsed.name : undefined);
548
- if (!name || !BUNDLE_NAME_PATTERN.test(name))
549
- return null;
550
- const bundle = {
551
- name,
552
- description: parsed.description,
553
- allow_exec: Boolean(parsed.allow_exec),
554
- backend: backend === 'keychain' ? undefined : backend,
555
- // Legacy wire key: the policy is persisted under `tier` (`session` == `hold`).
556
- policy: parsePolicy(parsed.tier),
557
- vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
558
- };
559
- if (typeof parsed.created_at === 'string')
560
- bundle.created_at = parsed.created_at;
561
- if (typeof parsed.updated_at === 'string')
562
- bundle.updated_at = parsed.updated_at;
563
- if (typeof parsed.last_used === 'string')
564
- bundle.last_used = parsed.last_used;
565
- if (parsed.meta && typeof parsed.meta === 'object')
566
- bundle.meta = parsed.meta;
567
- for (const key of Object.keys(bundle.vars)) {
568
- if (!BUNDLE_KEY_PATTERN.test(key))
569
- return null;
570
- }
571
- return bundle;
572
- }
573
- // Sentinel for the one-time metadata-ACL heal. Lives in the regenerable helpers
574
- // dir, so a cache wipe re-runs it harmlessly.
575
- const METADATA_NOACL_SENTINEL = 'bundles-metadata-noacl-healed';
576
- function metadataNoAclSentinelPath() {
577
- return path.join(getHelpersDir(), 'secrets-agent', METADATA_NOACL_SENTINEL);
578
- }
579
- function bundleMetadataAclHealed() {
580
- try {
581
- return fs.existsSync(metadataNoAclSentinelPath());
582
- }
583
- catch {
584
- return false;
585
- }
586
- }
587
- function markBundleMetadataAclHealed() {
588
- try {
589
- const file = metadataNoAclSentinelPath();
590
- fs.mkdirSync(path.dirname(file), { recursive: true });
591
- fs.writeFileSync(file, '', 'utf8');
592
- }
593
- catch {
594
- // Best effort — missing sentinel just re-runs the idempotent heal later.
595
- }
596
- }
597
- // No-ACL bundle-metadata name index. With hashed service names (#316), listing
598
- // metadata falls back to a broad `agents-cli.` scan that matches ACL'd secret
599
- // values and pops Touch ID. We keep a per-machine index of opaque metadata
600
- // storage names in the regenerable helpers dir to avoid that scan. Stale entries
601
- // only make `secrets list` cosmetically incomplete; resolve-by-name never uses it.
602
- function bundleMetaIndexPath() {
603
- // Test-only redirect so the suite never touches the real helpers dir.
604
- return (process.env.AGENTS_SECRETS_META_INDEX_FILE ||
605
- path.join(getHelpersDir(), 'secrets-agent', 'bundle-meta-index.json'));
606
- }
607
- // Fingerprint invalidates the index when the hashing key changes (#316 re-key),
608
- // so stale storage names don't make bundles "vanish".
609
- function metaIndexFingerprint() {
610
- return keychainServiceAlias(`${BUNDLE_META_PREFIX}meta-index-fingerprint`);
611
- }
612
- function readBundleMetaIndex() {
613
- // Never read/write the real index from a mock-backend test.
614
- if (isKeychainBackendOverridden())
615
- return null;
616
- try {
617
- const parsed = JSON.parse(fs.readFileSync(bundleMetaIndexPath(), 'utf-8'));
618
- if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.services))
619
- return null;
620
- if (parsed.fp !== metaIndexFingerprint())
621
- return null; // built under a different hashing key
622
- return parsed.services.every((s) => typeof s === 'string') ? parsed.services : null;
623
- }
624
- catch {
625
- return null;
626
- }
627
- }
628
- function writeBundleMetaIndex(services) {
629
- if (isKeychainBackendOverridden())
630
- return; // never write the real index from a mock-backend test
631
- try {
632
- const file = bundleMetaIndexPath();
633
- fs.mkdirSync(path.dirname(file), { recursive: true });
634
- const payload = { fp: metaIndexFingerprint(), services: [...new Set(services)].sort() };
635
- // Atomic write. Concurrent edits may still race and drop an entry, but that
636
- // only makes `secrets list` cosmetically incomplete and self-heals.
637
- const tmp = `${file}.${process.pid}.tmp`;
638
- fs.writeFileSync(tmp, JSON.stringify(payload), 'utf8');
639
- fs.renameSync(tmp, file);
640
- }
641
- catch {
642
- // Best effort — listBundles rebuilds from a broad scan on the next enumeration.
643
- }
644
- }
645
- // Append a storage name to an already-built index. No-op when null: a one-entry
646
- // index would hide every other bundle until the next rebuild.
647
- function addBundleToMetaIndex(name) {
648
- const cur = readBundleMetaIndex();
649
- if (cur === null)
650
- return;
651
- const svc = keychainServiceAlias(bundleMetaItem(name));
652
- if (!cur.includes(svc))
653
- writeBundleMetaIndex([...cur, svc]);
654
- }
655
- function removeBundleFromMetaIndex(name) {
656
- const cur = readBundleMetaIndex();
657
- if (cur === null)
658
- return;
659
- const svc = keychainServiceAlias(bundleMetaItem(name));
660
- if (cur.includes(svc))
661
- writeBundleMetaIndex(cur.filter((s) => s !== svc));
662
- }
663
- /** Test-only accessors for the metadata-name index. Never called in production. */
664
- export const __metaIndexForTest = {
665
- read: readBundleMetaIndex,
666
- write: writeBundleMetaIndex,
667
- add: addBundleToMetaIndex,
668
- remove: removeBundleFromMetaIndex,
669
- };
670
- /**
671
- * Re-write keychain metadata items without the biometry ACL. The caller supplies
672
- * the JSON already read, so contents are preserved and no extra read is issued.
673
- * Exported for tests.
674
- */
675
- export function healKeychainBundleMetadata(metaJsonByName) {
676
- let healed = 0;
677
- for (const [name, json] of metaJsonByName) {
678
- try {
679
- // keychainStore.set hashes the cleartext name back to the same service,
680
- // overwriting in place no-ACL. Per-item failures must not abort the rest.
681
- keychainStore.set(bundleMetaItem(name), json, { noAcl: true });
682
- healed++;
683
- }
684
- catch {
685
- /* keep healing the remaining items */
686
- }
687
- }
688
- return healed;
689
- }
690
- /**
691
- * One-time driver for healKeychainBundleMetadata. macOS + real keychain only;
692
- * gated by a sentinel. Best-effort: a heal failure never breaks listing.
693
- */
694
- export function healKeychainBundleMetadataAclOnce(metaJsonByName) {
695
- if (metaJsonByName.size === 0)
696
- return;
697
- if (process.platform !== 'darwin')
698
- return;
699
- if (isKeychainBackendOverridden())
700
- return;
701
- if (bundleMetadataAclHealed())
702
- return;
703
- try {
704
- if (healKeychainBundleMetadata(metaJsonByName) > 0)
705
- markBundleMetadataAclHealed();
706
- }
707
- catch {
708
- /* never let a heal failure break `secrets list` */
709
- }
710
- }
711
- export function listBundles() {
712
- const out = [];
713
- // Batch keychain metadata reads behind one prompt. Skip when the keychain
714
- // backend is routing to the file fallback to avoid listing file bundles twice.
715
- if (!keychainUsesFileFallback()) {
716
- let keychainServices = [];
717
- // Prefer the no-ACL metadata-name index (a silent file read) over the broad
718
- // `agents-cli.` keychain scan — that scan also matches ACL'd secret VALUE
719
- // items and pops Touch ID on every launch (see the index helpers above).
720
- // Absent index (first list after upgrade / a cache wipe) → do the one broad
721
- // scan and build the index from it, so that scan is the LAST one this machine
722
- // performs.
723
- const indexedServices = readBundleMetaIndex();
724
- if (indexedServices !== null) {
725
- keychainServices = indexedServices;
726
- }
727
- else {
728
- try {
729
- keychainServices = listKeychainItems(BUNDLE_META_PREFIX);
730
- writeBundleMetaIndex(keychainServices);
731
- }
732
- catch {
733
- keychainServices = [];
734
- }
735
- }
736
- // With hashed service names (macOS, #316) the enumerated services are
737
- // opaque (`agents-cli.h.<ns>.m`) — the display name is recovered from the
738
- // metadata JSON after the batch read below. Cleartext services (Linux,
739
- // pre-re-key items) still carry the name; it's kept as the parse hint so
740
- // legacy metadata without the persisted `name` field keeps listing.
741
- if (keychainServices.length > 0) {
742
- // Serve a broker-cached metadata snapshot so only the first list per hold
743
- // window prompts. Cache key is the name-set hash; policy edits can lag
744
- // cosmetically, but enforcement always reads live policy. Values are not cached.
745
- const useAgent = process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
746
- !isKeychainBackendOverridden() &&
747
- secretsAgentAutoEnabled();
748
- const nameSetHash = createHash('sha256')
749
- .update([...keychainServices].sort().join('\n'))
750
- .digest('hex')
751
- .slice(0, 32);
752
- const cached = useAgent ? agentGetMetaSync(nameSetHash) : null;
753
- if (cached) {
754
- for (const bundle of cached)
755
- out.push(bundle);
756
- }
757
- else {
758
- // Metadata is no-ACL by contract; attest silentNoAcl so headless listing
759
- // doesn't fail fast or pop a sheet.
760
- const fetched = getKeychainTokens(keychainServices, { silentNoAcl: true });
761
- const keychainBundles = [];
762
- for (const service of keychainServices) {
763
- const json = fetched.get(service);
764
- if (json === undefined)
765
- continue;
766
- const nameHint = service.startsWith(BUNDLE_META_PREFIX)
767
- ? service.slice(BUNDLE_META_PREFIX.length)
768
- : undefined;
769
- const bundle = parseBundleMeta(nameHint, json, 'keychain');
770
- if (bundle) {
771
- keychainBundles.push(bundle);
772
- }
773
- }
774
- for (const bundle of keychainBundles)
775
- out.push(bundle);
776
- // Populate the broker using the same hold cap as value reads.
777
- if (useAgent && keychainBundles.length > 0) {
778
- agentAutoLoadMetaSync(nameSetHash, keychainBundles, secretsHoldMs());
779
- }
780
- }
781
- }
782
- }
783
- // File-backed bundles: enumeration is silent; only decryption needs the passphrase.
784
- let fileServices = [];
785
- try {
786
- fileServices = fileStore.list(BUNDLE_META_PREFIX);
787
- }
788
- catch {
789
- fileServices = [];
790
- }
791
- const fileNames = fileServices
792
- .map((s) => s.slice(BUNDLE_META_PREFIX.length))
793
- .filter((n) => BUNDLE_NAME_PATTERN.test(n));
794
- for (const name of fileNames) {
795
- let json;
796
- try {
797
- json = fileItemStore.get(bundleMetaItem(name));
798
- }
799
- catch {
800
- // No passphrase: surface the name with empty vars so it isn't invisible.
801
- out.push({ name, backend: 'file', vars: {} });
802
- continue;
803
- }
804
- const bundle = parseBundleMeta(name, json, 'file');
805
- if (bundle)
806
- out.push(bundle);
807
- }
808
- if (getVaultSession().loggedIn && vaultExists()) {
809
- let vaultServices = [];
810
- try {
811
- vaultServices = vaultListItems(BUNDLE_META_PREFIX);
812
- }
813
- catch {
814
- vaultServices = [];
815
- }
816
- for (const service of vaultServices) {
817
- const name = service.slice(BUNDLE_META_PREFIX.length);
818
- if (!BUNDLE_NAME_PATTERN.test(name))
819
- continue;
820
- let json;
821
- try {
822
- json = vaultStore.get(bundleMetaItem(name));
823
- }
824
- catch {
825
- out.push({ name, backend: 'vault', vars: {} });
826
- continue;
827
- }
828
- const bundle = parseBundleMeta(name, json, 'vault');
829
- if (bundle)
830
- out.push(bundle);
831
- }
832
- }
833
- const activeNames = new Set(filterNamesForActiveResourceProfile('secrets', out.map((b) => b.name)));
834
- return out.filter((bundle) => activeNames.has(bundle.name)).sort((a, b) => a.name.localeCompare(b.name));
835
- }
836
- export function describeBundle(bundle) {
837
- const out = [];
838
- for (const [key, raw] of Object.entries(bundle.vars)) {
839
- const parsed = parseBundleValue(raw);
840
- if ('literal' in parsed) {
841
- out.push({ key, kind: 'literal', detail: '' });
842
- }
843
- else {
844
- out.push({ key, kind: parsed.ref.provider, detail: parsed.ref.value });
845
- }
846
- }
847
- return out;
848
- }
849
- // Bump `last_used` at most once per throttle window. The passed bundle is often
850
- // the broker's snapshot, which can be stale, so re-read the authoritative
851
- // metadata and write ONLY the timestamp. Failures are swallowed.
852
- // Set AGENTS_NO_USAGE_TRACK=1 to disable entirely.
853
- export function stampLastUsed(bundle) {
854
- if (process.env.AGENTS_NO_USAGE_TRACK)
855
- return;
856
- const nowMs = Date.now();
857
- if (bundle.last_used) {
858
- const prev = Date.parse(bundle.last_used);
859
- if (Number.isFinite(prev) && nowMs - prev < LAST_USED_THROTTLE_MS)
860
- return;
861
- }
862
- try {
863
- const fresh = readBundle(bundle.name); // throws if the bundle is gone — swallowed below
864
- const stamp = new Date(nowMs).toISOString();
865
- fresh.last_used = stamp;
866
- bundle.last_used = stamp;
867
- // Stamping fires on every broker hit; evicting would destroy the cache.
868
- writeBundle(fresh, { skipBrokerEviction: true });
869
- }
870
- catch {
871
- // Swallow — telemetry must never block secret resolution.
872
- }
873
- }
874
- /**
875
- * Abort if any selected key's per-key `expires` date is in the past.
876
- */
877
- function assertNotExpired(bundle, selectedKeys, allowExpired) {
878
- if (allowExpired)
879
- return;
880
- if (!bundle.meta)
881
- return;
882
- const now = Date.now();
883
- for (const key of selectedKeys) {
884
- const meta = bundle.meta[key];
885
- if (!meta?.expires)
886
- continue;
887
- // expires is 'YYYY-MM-DD'; treat as end-of-day UTC.
888
- const expiry = new Date(meta.expires + 'T23:59:59Z').getTime();
889
- if (expiry < now) {
890
- throw new Error(`Bundle '${bundle.name}' key '${key}' expired on ${meta.expires}. ` +
891
- `Rotate it with: agents secrets rotate ${bundle.name} ${key}` +
892
- ` (or pass --allow-expired to skip this check).`);
893
- }
894
- }
895
- }
896
- /**
897
- * Select the requested key subset, failing loud if any key is absent.
898
- */
899
- function selectRequestedKeys(bundle, requested) {
900
- const req = requested?.length ? requested : undefined;
901
- if (req) {
902
- const missing = req.filter((k) => !(k in bundle.vars));
903
- if (missing.length > 0) {
904
- const available = Object.keys(bundle.vars).join(', ') || '(none)';
905
- throw new Error(`Bundle '${bundle.name}' does not contain key(s): ${missing.join(', ')}. Available: ${available}.`);
906
- }
907
- }
908
- return new Set(req ?? Object.keys(bundle.vars));
909
- }
910
- function assignResolvedEnvValue(env, bundle, storageKey, value, keyMode, owners) {
911
- const envKey = keyMode === 'storage' ? storageKey : bundleKeyToEnvKey(storageKey);
912
- const previous = owners.get(envKey);
913
- if (previous && previous !== storageKey) {
914
- throw new Error(`Bundle '${bundle.name}' maps multiple keys to '${envKey}': ${previous}, ${storageKey}. ` +
915
- `Select one account variant with --keys.`);
916
- }
917
- owners.set(envKey, storageKey);
918
- env[envKey] = value;
919
- }
920
- function projectResolvedEnv(bundle, env, selectedKeys, keyMode) {
921
- if (keyMode === 'storage') {
922
- const out = {};
923
- for (const key of selectedKeys) {
924
- if (key in env)
925
- out[key] = env[key];
926
- }
927
- return out;
928
- }
929
- let needsProjection = false;
930
- const owners = new Map();
931
- for (const key of selectedKeys) {
932
- const envKey = bundleKeyToEnvKey(key);
933
- if (envKey !== key)
934
- needsProjection = true;
935
- const previous = owners.get(envKey);
936
- if (previous && previous !== key) {
937
- throw new Error(`Bundle '${bundle.name}' maps multiple keys to '${envKey}': ${previous}, ${key}. ` +
938
- `Select one account variant with --keys.`);
939
- }
940
- owners.set(envKey, key);
941
- }
942
- if (!needsProjection) {
943
- const envKeys = Object.keys(env);
944
- if (selectedKeys.size === envKeys.length && envKeys.every((key) => selectedKeys.has(key)))
945
- return env;
946
- const out = {};
947
- for (const key of selectedKeys) {
948
- if (key in env)
949
- out[key] = env[key];
950
- }
951
- return out;
952
- }
953
- const out = {};
954
- for (const key of selectedKeys) {
955
- if (key in env)
956
- out[bundleKeyToEnvKey(key)] = env[key];
957
- }
958
- return out;
959
- }
960
- export function canCacheResolvedEnv(bundle, selectedKeys, keyMode) {
961
- if (selectedKeys.size !== Object.keys(bundle.vars).length)
962
- return false;
963
- if (keyMode === 'storage')
964
- return true;
965
- for (const key of selectedKeys) {
966
- if (bundleKeyToEnvKey(key) !== key)
967
- return false;
968
- }
969
- return true;
970
- }
971
- /**
972
- * Apply --keys and --allow-expired to a broker snapshot so the fast path
973
- * mirrors the slow path's gates. Exported for tests.
974
- */
975
- export function filterAgentHitBySubsetAndExpiry(hit, opts) {
976
- const selectedKeys = selectRequestedKeys(hit.bundle, opts.keys);
977
- assertNotExpired(hit.bundle, [...selectedKeys], opts.allowExpired ?? false);
978
- const env = projectResolvedEnv(hit.bundle, hit.env, selectedKeys, opts.keyMode);
979
- // Return the cached reference unchanged when no subset/projection was applied.
980
- if (env === hit.env)
981
- return hit;
982
- return { bundle: hit.bundle, env };
983
- }
984
- /**
985
- * Fail loud when remote bundle resolution is asked for flags the SSH resolver
986
- * does not yet thread. Exported so callers share the same error text.
987
- */
988
- export function assertRemoteBundleFlagsUnsupported(bundleName, host, opts, flagLabels) {
989
- const hasKeys = Array.isArray(opts.keys) && opts.keys.length > 0;
990
- if (!hasKeys && !opts.allowExpired)
991
- return;
992
- throw new Error(`Bundle '${bundleName}@${host}': ${flagLabels.keysFlag} and ${flagLabels.allowExpiredFlag} are not supported for remote (bundle@host) bundles yet. ` +
993
- `Drop the flag or resolve the bundle locally.`);
994
- }
995
- /**
996
- * Build the right error for a missing `keychain:` ref. Classifies
997
- * present-but-unreadable (locked/denied) vs genuinely absent so `view` and reads
998
- * stay consistent. Only keychain has a locked state; file/vault misses are absent.
999
- */
1000
- function missingBundleKeychainItemError(bundleName, key, item, backendKind) {
1001
- if (backendKind === 'keychain') {
1002
- let present;
1003
- try {
1004
- present = hasKeychainToken(item);
1005
- }
1006
- catch (err) {
1007
- return new Error(`Bundle '${bundleName}' key '${key}': ${err.message}`);
1008
- }
1009
- if (present) {
1010
- return new Error(`Bundle '${bundleName}' key '${key}': stored item '${item}' is present but could not be read — ` +
1011
- `the keychain is locked or Touch ID was not granted for this read. ` +
1012
- `Run: agents secrets unlock ${bundleName} (or read it once at an interactive terminal so Touch ID can be granted). ` +
1013
- `Do NOT run 'agents secrets add' — the secret is already stored and adding would overwrite it.`);
1014
- }
1015
- }
1016
- return new Error(`Bundle '${bundleName}' key '${key}': stored item '${item}' not found. ` +
1017
- `Run: agents secrets add ${bundleName} ${key}`);
1018
- }
1019
- /**
1020
- * Shared per-key resolver for `resolveBundleEnv` and `readAndResolveBundleEnv`.
1021
- * Looks up keychain items by cleartext name then hashed alias; classifies misses
1022
- * consistently so the two paths cannot diverge.
1023
- */
1024
- function assembleBundleEnv(bundle, selectedKeys, parsedByKey, fetched, keyMode, backendKind) {
1025
- const env = {};
1026
- const owners = new Map();
1027
- for (const [key] of Object.entries(bundle.vars)) {
1028
- if (!selectedKeys.has(key))
1029
- continue;
1030
- const parsed = parsedByKey.get(key);
1031
- if ('literal' in parsed) {
1032
- assignResolvedEnvValue(env, bundle, key, parsed.literal, keyMode, owners);
1033
- continue;
1034
- }
1035
- if (parsed.ref.provider === 'keychain') {
1036
- const item = secretsKeychainItem(bundle.name, parsed.ref.value);
1037
- const value = fetched.get(item) ?? fetched.get(keychainServiceAlias(item));
1038
- if (value === undefined) {
1039
- throw missingBundleKeychainItemError(bundle.name, key, item, backendKind);
1040
- }
1041
- assignResolvedEnvValue(env, bundle, key, value, keyMode, owners);
1042
- continue;
1043
- }
1044
- try {
1045
- const value = resolveRef(parsed.ref, {
1046
- allowExec: bundle.allow_exec,
1047
- keychainItemFor: (shortId) => secretsKeychainItem(bundle.name, shortId),
1048
- });
1049
- assignResolvedEnvValue(env, bundle, key, value, keyMode, owners);
1050
- }
1051
- catch (err) {
1052
- throw new Error(`Bundle '${bundle.name}' key '${key}': ${err.message}`);
1053
- }
1054
- }
1055
- return env;
1056
- }
1057
- // Resolve the bundle into a flat env map, batching keychain refs into one read
1058
- // so macOS shows one Touch ID prompt. Literals/env/file/exec refs resolve inline.
1059
- export function resolveBundleEnv(bundle, _opts = {}) {
1060
- stampLastUsed(bundle);
1061
- const selectedKeys = selectRequestedKeys(bundle, _opts.keys);
1062
- assertNotExpired(bundle, [...selectedKeys], _opts.allowExpired ?? false);
1063
- const parsedByKey = new Map();
1064
- const keychainItemsToFetch = [];
1065
- for (const [key, raw] of Object.entries(bundle.vars)) {
1066
- if (!selectedKeys.has(key))
1067
- continue;
1068
- const parsed = parseBundleValue(raw);
1069
- parsedByKey.set(key, parsed);
1070
- if ('ref' in parsed && parsed.ref.provider === 'keychain') {
1071
- keychainItemsToFetch.push(secretsKeychainItem(bundle.name, parsed.ref.value));
1072
- }
1073
- }
1074
- const store = itemStore(bundle.backend ?? 'keychain');
1075
- // Direct getKeychainTokens so `never`-policy bundles attest silentNoAcl in
1076
- // headless contexts, while ACL'd bundles fail fast.
1077
- const fetched = keychainItemsToFetch.length > 0
1078
- ? (bundle.backend ?? 'keychain') === 'keychain'
1079
- ? getKeychainTokens(keychainItemsToFetch, { silentNoAcl: bundlePolicy(bundle) === 'never' })
1080
- : store.getBatch(keychainItemsToFetch)
1081
- : new Map();
1082
- const env = assembleBundleEnv(bundle, selectedKeys, parsedByKey, fetched, _opts.keyMode, bundle.backend ?? 'keychain');
1083
- void _opts.caller; // informational only
1084
- return env;
1085
- }
1086
- /**
1087
- * True when the current process is a background / non-interactive context that
1088
- * must NEVER raise a Keychain biometry prompt on the interactive user's screen.
1089
- * Re-exported from ./headless.js — the detector lives there so the raw-read
1090
- * path in index.ts can share it without a bundles↔index import cycle. See that
1091
- * module for the full contract.
1092
- */
1093
- export { isHeadlessSecretsContext, isAgentInvocationContext } from './headless.js';
1094
- /**
1095
- * Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
1096
- * `readBundle` + `resolveBundleEnv` used to issue two LAContext calls (two
1097
- * prompts). This collapses them into one batch that includes the metadata item.
1098
- */
1099
- export function readAndResolveBundleEnv(name, opts = {}) {
1100
- validateBundleName(name);
1101
- assertNameActiveInResourceProfile('secrets', name);
1102
- const backend = bundleBackend(name);
1103
- // Fast-path: broker-held snapshot ⇒ no Touch ID. Soft: any failure falls
1104
- // through to the real keychain read. macOS/keychain only.
1105
- if (backend === 'keychain' && !opts.noAgent && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
1106
- // Falls back to GLOBAL so an unscoped unlock is visible in any harness.
1107
- const harness = opts.agent || process.env.AGENTS_AGENT_NAME || GLOBAL_HARNESS;
1108
- const hit = agentGetSync(name, harness);
1109
- if (hit) {
1110
- const denied = (opts.keys ?? []).filter((key) => hit.lease && !hit.lease.keys.includes(key));
1111
- if (denied.length > 0) {
1112
- emitSecretAudit({ event: 'secrets.lease-denied', bundle: name, operation: opts.caller, source: 'agent', status: 'error', keys: denied, keyCount: denied.length, agent: harness, error: 'key outside lease scope' });
1113
- throw new Error(`Secret lease '${hit.lease?.id}' does not grant key(s): ${denied.join(', ')}`);
1114
- }
1115
- // Apply the same subset and expiry gates as the slow path.
1116
- const filtered = filterAgentHitBySubsetAndExpiry(hit, opts);
1117
- stampLastUsed(filtered.bundle);
1118
- emitSecretAudit({
1119
- event: 'secrets.get',
1120
- bundle: name,
1121
- operation: opts.caller,
1122
- status: 'success',
1123
- source: 'agent',
1124
- keyCount: Object.keys(filtered.env).length,
1125
- agent: harness,
1126
- });
1127
- return filtered;
1128
- }
1129
- // Durable-session fallback: after restart the broker RAM is empty, but a
1130
- // no-ACL session item lets us re-warm the broker without Touch ID.
1131
- const resolved = resolveSession(name, Date.now(), harness);
1132
- if (resolved) {
1133
- const session = resolved.entry;
1134
- const denied = (opts.keys ?? []).filter((key) => session.lease && !session.lease.keys.includes(key));
1135
- if (denied.length > 0) {
1136
- emitSecretAudit({ event: 'secrets.lease-denied', bundle: name, operation: opts.caller, source: 'session', status: 'error', keys: denied, keyCount: denied.length, agent: harness, error: 'key outside lease scope' });
1137
- throw new Error(`Secret lease '${session.lease?.id}' does not grant key(s): ${denied.join(', ')}`);
1138
- }
1139
- const filtered = filterAgentHitBySubsetAndExpiry({ bundle: session.bundle, env: session.env }, opts);
1140
- stampLastUsed(filtered.bundle);
1141
- // Re-warm under the scope the grant was made in so a global grant isn't
1142
- // narrowed to the asking harness. No snapshotAt: the session bundle predates
1143
- // this read; claiming freshness would defeat eviction tombstones.
1144
- agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), resolved.harness, session.lease);
1145
- emitSecretAudit({
1146
- event: 'secrets.get',
1147
- bundle: name,
1148
- operation: opts.caller,
1149
- status: 'success',
1150
- source: 'session',
1151
- keyCount: Object.keys(filtered.env).length,
1152
- agent: harness,
1153
- });
1154
- return filtered;
1155
- }
1156
- }
1157
- const interactiveUnlock = opts.interactiveUnlock ?? false;
1158
- // A `never`-policy bundle is prompt-free; attest silentNoAcl once verified.
1159
- let verifiedNoAclBundle = false;
1160
- if (opts.agentOnly && backend === 'keychain' && !interactiveUnlock && !keychainAgentOnlyBypassForTest) {
1161
- try {
1162
- verifiedNoAclBundle = bundlePolicy(readBundle(name)) === 'never';
1163
- }
1164
- catch { /* fail closed */ }
1165
- if (!verifiedNoAclBundle) {
1166
- throw new Error(`Secrets bundle '${name}' is not unlocked in the secrets agent. ` +
1167
- `Run 'agents secrets unlock ${name}' in a terminal first — an agent launch ` +
1168
- `never raises a Touch ID sheet on its own.`);
1169
- }
1170
- }
1171
- // Fail loud when the broker is disabled and this would otherwise prompt.
1172
- // Never-policy bundles remain silent; vault/file are unaffected.
1173
- if (backend === 'keychain' && !verifiedNoAclBundle && !isSecretsBrokerEnabled() && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
1174
- throw new Error(`Secrets broker is disabled — re-enable with 'agents daemon services enable secrets-broker'. ` +
1175
- `If you meant to read directly from the keychain, set AGENTS_SECRETS_NO_AGENT=1.`);
1176
- }
1177
- if (backend === 'vault')
1178
- assertVaultBackendUsable(name);
1179
- const store = itemStore(backend);
1180
- const metaItem = bundleMetaItem(name);
1181
- const bundleSecretPrefix = `${SECRETS_ITEM_PREFIX}${name}.`;
1182
- let enumeratedSecretItems = [];
1183
- // Agent-only launches must not enumerate the keychain: hashed names turn a
1184
- // per-bundle prefix into a broad scan that evaluates unrelated ACLs (RUSH-2440).
1185
- // Interactive reads keep the legacy enumeration path.
1186
- if (backend !== 'keychain' || !opts.agentOnly) {
1187
- try {
1188
- enumeratedSecretItems = store.list(bundleSecretPrefix);
1189
- }
1190
- catch {
1191
- enumeratedSecretItems = [];
1192
- }
1193
- }
1194
- const reason = opts.caller
1195
- ? `read ${name} secrets (for ${opts.caller})`
1196
- : `read ${name} secrets`;
1197
- // Capture snapshotAt before the first read so broker eviction tombstones beat
1198
- // any concurrent load.
1199
- const snapshotAt = Date.now();
1200
- // Fetch metadata (always no-ACL) and derive secret item names from declared
1201
- // keys, eliminating the broad keychain scan that triggered Touch ID (RUSH-2440).
1202
- const metaFetched = backend === 'keychain'
1203
- ? getKeychainTokens([metaItem], { silentNoAcl: true })
1204
- : store.getBatch([...new Set([metaItem, ...enumeratedSecretItems])]);
1205
- const json = metaFetched.get(metaItem);
1206
- if (json === undefined) {
1207
- if (vaultExists() && !getVaultSession().loggedIn) {
1208
- throw new Error(`Synced secrets are locked. Run: agents secrets vault unlock`);
1209
- }
1210
- throw new Error(`Secrets bundle '${name}' not found.`);
1211
- }
1212
- let parsed;
1213
- try {
1214
- parsed = JSON.parse(json);
1215
- }
1216
- catch {
1217
- throw new Error(`Bundle '${name}' is malformed.`);
1218
- }
1219
- if (!parsed || typeof parsed !== 'object') {
1220
- throw new Error(`Bundle '${name}' is malformed.`);
1221
- }
1222
- // Derive exact storage names from declared keychain refs. The env key and
1223
- // stored item name may differ, so vars keys alone would read the wrong secret.
1224
- const declaredSecretItems = [];
1225
- if (parsed.vars && typeof parsed.vars === 'object') {
1226
- for (const raw of Object.values(parsed.vars)) {
1227
- const value = parseBundleValue(raw);
1228
- if ('ref' in value && value.ref.provider === 'keychain') {
1229
- declaredSecretItems.push(secretsKeychainItem(name, value.ref.value));
1230
- }
1231
- }
1232
- }
1233
- const secretItems = [...new Set([...enumeratedSecretItems, ...declaredSecretItems])];
1234
- // Fetch metadata and secret values in one batch.
1235
- const fetched = backend === 'keychain'
1236
- ? getKeychainTokens([...new Set([metaItem, ...secretItems])], {
1237
- agent: opts.agent || process.env.AGENTS_AGENT_NAME || 'Agents CLI',
1238
- bundle: name,
1239
- sessionId: process.env.AGENT_SESSION_ID || process.env.AGENTS_SESSION_ID,
1240
- reason: opts.caller ? `to ${opts.caller}` : reason,
1241
- duration: opts.duration || humanUnlockDuration(secretsHoldMs()),
1242
- defaultPolicy: secretsDefaultPolicy(),
1243
- forceDuration: Boolean(opts.duration),
1244
- silentNoAcl: verifiedNoAclBundle,
1245
- })
1246
- // File/vault: reuse the initial batch to avoid double-decrypting.
1247
- : metaFetched;
1248
- const bundle = {
1249
- name,
1250
- description: parsed.description,
1251
- allow_exec: Boolean(parsed.allow_exec),
1252
- backend: backend === 'keychain' ? undefined : backend,
1253
- policy: parsePolicy(parsed.tier),
1254
- vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
1255
- };
1256
- if (typeof parsed.created_at === 'string')
1257
- bundle.created_at = parsed.created_at;
1258
- if (typeof parsed.updated_at === 'string')
1259
- bundle.updated_at = parsed.updated_at;
1260
- if (typeof parsed.last_used === 'string')
1261
- bundle.last_used = parsed.last_used;
1262
- if (parsed.meta && typeof parsed.meta === 'object')
1263
- bundle.meta = parsed.meta;
1264
- for (const key of Object.keys(bundle.vars)) {
1265
- validateEnvKey(key);
1266
- }
1267
- const selectedKeys = selectRequestedKeys(bundle, opts.keys);
1268
- assertNotExpired(bundle, [...selectedKeys], opts.allowExpired ?? false);
1269
- stampLastUsed(bundle);
1270
- const parsedByKey = new Map();
1271
- const keychainKeys = [];
1272
- const kindCounts = {};
1273
- for (const [key, raw] of Object.entries(bundle.vars)) {
1274
- if (!selectedKeys.has(key))
1275
- continue;
1276
- const p = parseBundleValue(raw);
1277
- parsedByKey.set(key, p);
1278
- const kind = 'literal' in p ? 'literal' : p.ref.provider;
1279
- kindCounts[kind] = (kindCounts[kind] ?? 0) + 1;
1280
- if ('ref' in p && p.ref.provider === 'keychain') {
1281
- keychainKeys.push(key);
1282
- }
1283
- }
1284
- const keys = [...selectedKeys].sort();
1285
- keychainKeys.sort();
1286
- const emitReadAudit = (status, err) => {
1287
- emitSecretAudit({
1288
- event: 'secrets.get',
1289
- bundle: bundle.name,
1290
- operation: opts.caller,
1291
- status,
1292
- keyCount: keys.length,
1293
- keys,
1294
- keychainKeys,
1295
- kindCounts,
1296
- agent: opts.agent,
1297
- error: err instanceof Error ? err.message : (err ? String(err) : undefined),
1298
- });
1299
- };
1300
- try {
1301
- const env = assembleBundleEnv(bundle, selectedKeys, parsedByKey, fetched, opts.keyMode, backend);
1302
- emitReadAudit('success');
1303
- // Auto-cache into the broker so the next read is silent. Synchronous warm
1304
- // when a broker is already up; cold-start uses the detached path.
1305
- if (backend === 'keychain' &&
1306
- !opts.noAgent &&
1307
- process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
1308
- bundlePolicy(bundle) === 'hold' &&
1309
- secretsAgentAutoEnabled() &&
1310
- canCacheResolvedEnv(bundle, selectedKeys, opts.keyMode)) {
1311
- agentAutoLoadSync(name, bundle, env, secretsHoldMs(), opts.agent || process.env.AGENTS_AGENT_NAME || GLOBAL_HARNESS, undefined, snapshotAt);
1312
- }
1313
- return { bundle, env };
1314
- }
1315
- catch (err) {
1316
- emitReadAudit('error', err);
1317
- throw err;
1318
- }
1319
- }
1320
- export function humanUnlockDuration(ms) {
1321
- const days = Math.round(ms / (24 * 60 * 60 * 1000));
1322
- if (days >= 1)
1323
- return `${days} day${days === 1 ? '' : 's'}`;
1324
- const hours = Math.round(ms / (60 * 60 * 1000));
1325
- if (hours >= 1)
1326
- return `${hours} hour${hours === 1 ? '' : 's'}`;
1327
- const minutes = Math.max(1, Math.round(ms / (60 * 1000)));
1328
- return `${minutes} minute${minutes === 1 ? '' : 's'}`;
1329
- }
1330
- // Build a keychain ref expression from a bundle+key pair, for storage in the bundle metadata.
1331
- export function keychainRef(key) {
1332
- return `keychain:${key}`;
1333
- }
1334
- /**
1335
- * Rotate a keychain-backed secret. Errors if the key is absent; preserves meta
1336
- * unless cleared or patched.
1337
- */
1338
- export function rotateBundleSecret(bundle, key, opts) {
1339
- validateBundleName(bundle.name);
1340
- validateEnvKey(key);
1341
- if (!(key in bundle.vars)) {
1342
- throw new Error(`Key '${key}' not in bundle '${bundle.name}'. Use 'agents secrets add' to add a new key.`);
1343
- }
1344
- const raw = bundle.vars[key];
1345
- // Only keychain-backed values are rotated.
1346
- if (typeof raw !== 'string' || !raw.startsWith('keychain:')) {
1347
- throw new Error(`Key '${key}' in bundle '${bundle.name}' is not keychain-backed; cannot rotate.`);
1348
- }
1349
- const shortId = raw.slice('keychain:'.length);
1350
- const item = secretsKeychainItem(bundle.name, shortId);
1351
- itemStore(bundle.backend ?? 'keychain').set(item, opts.newValue, { noAcl: bundlePolicy(bundle) === 'never' });
1352
- if (opts.clearMeta) {
1353
- if (bundle.meta)
1354
- delete bundle.meta[key];
1355
- }
1356
- else if (opts.meta && Object.keys(opts.meta).length > 0) {
1357
- if (!bundle.meta)
1358
- bundle.meta = {};
1359
- const current = bundle.meta[key] ?? {};
1360
- const patched = { ...current };
1361
- if (opts.meta.type !== undefined)
1362
- patched.type = opts.meta.type;
1363
- if (opts.meta.expires !== undefined)
1364
- patched.expires = opts.meta.expires;
1365
- if (opts.meta.note !== undefined)
1366
- patched.note = opts.meta.note;
1367
- bundle.meta[key] = patched;
1368
- }
1369
- writeBundle(bundle);
1370
- }
1371
- /**
1372
- * Reconcile keychain value items to the bundle's current policy. macOS gates
1373
- * reads on each item's ACL, not the bundle's declared policy, so a policy change
1374
- * alone would leave stale ACLs. hold/always → never strips ACL; never → *
1375
- * re-attaches it. Non-keychain backends no-op.
1376
- */
1377
- export function reAclBundleItems(bundle) {
1378
- if ((bundle.backend ?? 'keychain') !== 'keychain') {
1379
- writeBundle(bundle);
1380
- return;
1381
- }
1382
- const store = itemStore('keychain');
1383
- const entries = keychainItemsForBundle(bundle);
1384
- if (entries.length === 0) {
1385
- writeBundle(bundle);
1386
- return;
1387
- }
1388
- // One batch read ⇒ at most one Touch ID for the whole reconcile.
1389
- const values = store.getBatch(entries.map((e) => e.item));
1390
- const rewrite = new Map();
1391
- for (const { item } of entries) {
1392
- const value = values.get(item);
1393
- // A declared key with no readable value is corruption — fail loud.
1394
- if (value === undefined) {
1395
- throw new Error(`Cannot change policy for '${bundle.name}': a keychain value is missing or unreadable. Rotate that key, then retry.`);
1396
- }
1397
- rewrite.set(item, value);
1398
- }
1399
- // writeBundleWithItems applies the correct noAcl flag and evicts the broker.
1400
- writeBundleWithItems(bundle, rewrite);
1401
- }
1402
- /**
1403
- * Rename a bundle: copy metadata + keychain values to the new name, then delete
1404
- * the source. Steps are ordered so a copy-phase failure leaves the source intact.
1405
- */
1406
- export function renameBundle(oldName, newName, opts = {}) {
1407
- validateBundleName(oldName);
1408
- validateBundleName(newName);
1409
- if (oldName === newName) {
1410
- throw new Error(`Bundle name unchanged ('${oldName}').`);
1411
- }
1412
- if (!bundleExists(oldName)) {
1413
- throw new Error(`Bundle '${oldName}' not found.`);
1414
- }
1415
- const source = readBundle(oldName);
1416
- const store = itemStore(source.backend ?? 'keychain');
1417
- if (bundleExists(newName)) {
1418
- if (!opts.force) {
1419
- throw new Error(`Bundle '${newName}' already exists. Use --force to overwrite.`);
1420
- }
1421
- const dest = readBundle(newName);
1422
- const destStore = itemStore(dest.backend ?? 'keychain');
1423
- for (const { item } of keychainItemsForBundle(dest)) {
1424
- destStore.delete(item);
1425
- }
1426
- deleteBundle(newName);
1427
- }
1428
- // Copy to the new name, leaving old items in place until cleanup.
1429
- const sourceItems = keychainItemsForBundle(source);
1430
- for (const { key, item: oldItem } of sourceItems) {
1431
- const raw = source.vars[key];
1432
- if (typeof raw !== 'string' || !raw.startsWith('keychain:'))
1433
- continue;
1434
- const shortId = raw.slice('keychain:'.length);
1435
- const newItem = secretsKeychainItem(newName, shortId);
1436
- const value = store.get(oldItem);
1437
- store.set(newItem, value, { noAcl: bundlePolicy(source) === 'never' });
1438
- }
1439
- const renamed = { ...source, name: newName };
1440
- writeBundle(renamed);
1441
- // Cleanup: delete the old per-key items, then the old metadata.
1442
- for (const { item: oldItem } of sourceItems) {
1443
- store.delete(oldItem);
1444
- }
1445
- deleteBundle(oldName);
1446
- emit('secrets.rename', { module: 'secrets', from: oldName, to: newName });
1447
- }
1448
- /**
1449
- * The item store (keychain or encrypted file) for a bundle's per-key secrets.
1450
- * Pass the resolved backend (`bundle.backend ?? 'keychain'`).
1451
- */
1452
- export function bundleItemStore(backend, opts) {
1453
- const store = itemStore(backend ?? 'keychain');
1454
- // `never`-policy bundles write per-key values without the biometry ACL.
1455
- if (opts?.noAcl) {
1456
- return { ...store, set: (item, value) => store.set(item, value, { noAcl: true }) };
1457
- }
1458
- return store;
1459
- }
1460
- export function keychainItemsForBundle(bundle) {
1461
- const items = [];
1462
- for (const [key, raw] of Object.entries(bundle.vars)) {
1463
- const parsed = parseBundleValue(raw);
1464
- if ('ref' in parsed && parsed.ref.provider === 'keychain') {
1465
- items.push({ key, item: secretsKeychainItem(bundle.name, parsed.ref.value) });
1466
- }
1467
- }
1468
- return items;
1469
- }
1470
- export function parseDotenv(content) {
1471
- const out = {};
1472
- for (const raw of content.split('\n')) {
1473
- const line = raw.trim();
1474
- if (!line || line.startsWith('#'))
1475
- continue;
1476
- const stripped = line.startsWith('export ') ? line.slice('export '.length) : line;
1477
- const eq = stripped.indexOf('=');
1478
- if (eq <= 0)
1479
- continue;
1480
- const key = stripped.slice(0, eq).trim();
1481
- let value = stripped.slice(eq + 1).trim();
1482
- if ((value.startsWith('"') && value.endsWith('"')) ||
1483
- (value.startsWith("'") && value.endsWith("'"))) {
1484
- value = value.slice(1, -1);
1485
- }
1486
- if (BUNDLE_KEY_PATTERN.test(key)) {
1487
- out[key] = value;
1488
- }
1489
- }
1490
- return out;
1491
- }
1492
- export async function migrateLegacyBundles(confirmBundle) {
1493
- const home = os.homedir();
1494
- const dirs = [
1495
- path.join(home, '.agents', 'secrets'),
1496
- path.join(home, '.agents-system', 'secrets'),
1497
- ];
1498
- let migrated = 0;
1499
- for (const dir of dirs) {
1500
- let entries;
1501
- try {
1502
- entries = fs.readdirSync(dir);
1503
- }
1504
- catch {
1505
- continue;
1506
- }
1507
- const ymls = entries.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
1508
- for (const entry of ymls) {
1509
- const file = path.join(dir, entry);
1510
- const name = entry.replace(/\.(yml|yaml)$/, '');
1511
- let parsed;
1512
- try {
1513
- validateBundleName(name);
1514
- const raw = fs.readFileSync(file, 'utf-8');
1515
- parsed = yaml.parse(raw);
1516
- }
1517
- catch {
1518
- // Leave malformed YAMLs in place so the user can inspect them.
1519
- continue;
1520
- }
1521
- if (!parsed || typeof parsed !== 'object')
1522
- continue;
1523
- const bundle = {
1524
- name,
1525
- description: parsed.description,
1526
- allow_exec: Boolean(parsed.allow_exec),
1527
- vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
1528
- };
1529
- const keys = Object.keys(bundle.vars);
1530
- for (const key of keys) {
1531
- validateEnvKey(key);
1532
- }
1533
- const proceed = await confirmBundle({ name, file, keys });
1534
- if (!proceed)
1535
- continue;
1536
- writeBundle(bundle);
1537
- fs.unlinkSync(file);
1538
- migrated++;
1539
- }
1540
- try {
1541
- if (fs.readdirSync(dir).length === 0)
1542
- fs.rmdirSync(dir);
1543
- }
1544
- catch { /* not empty or already gone */ }
1545
- }
1546
- return migrated;
1547
- }