@phnx-labs/agents-cli 1.20.34 → 1.20.36

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 +113 -0
  2. package/LICENSE +185 -21
  3. package/README.md +13 -5
  4. package/dist/commands/audit.d.ts +14 -0
  5. package/dist/commands/audit.js +68 -0
  6. package/dist/commands/browser.js +82 -8
  7. package/dist/commands/check.d.ts +15 -0
  8. package/dist/commands/check.js +84 -0
  9. package/dist/commands/cloud.js +143 -3
  10. package/dist/commands/computer-actions.d.ts +15 -0
  11. package/dist/commands/computer-actions.js +120 -22
  12. package/dist/commands/computer.js +81 -0
  13. package/dist/commands/cost.js +17 -12
  14. package/dist/commands/daemon.js +4 -1
  15. package/dist/commands/doctor.d.ts +1 -0
  16. package/dist/commands/doctor.js +54 -97
  17. package/dist/commands/events.d.ts +16 -0
  18. package/dist/commands/events.js +173 -0
  19. package/dist/commands/exec.d.ts +19 -0
  20. package/dist/commands/exec.js +283 -12
  21. package/dist/commands/hosts.js +37 -29
  22. package/dist/commands/inspect.d.ts +2 -0
  23. package/dist/commands/inspect.js +62 -16
  24. package/dist/commands/lock.d.ts +12 -0
  25. package/dist/commands/lock.js +70 -0
  26. package/dist/commands/logs.d.ts +17 -0
  27. package/dist/commands/logs.js +139 -0
  28. package/dist/commands/message.d.ts +15 -0
  29. package/dist/commands/message.js +56 -0
  30. package/dist/commands/models.d.ts +3 -0
  31. package/dist/commands/models.js +27 -8
  32. package/dist/commands/permissions.js +9 -2
  33. package/dist/commands/repo.d.ts +34 -0
  34. package/dist/commands/repo.js +243 -65
  35. package/dist/commands/resource-view.d.ts +20 -0
  36. package/dist/commands/resource-view.js +90 -28
  37. package/dist/commands/routines.js +101 -5
  38. package/dist/commands/secrets-migrate.js +106 -57
  39. package/dist/commands/secrets.d.ts +31 -18
  40. package/dist/commands/secrets.js +196 -91
  41. package/dist/commands/serve.d.ts +10 -0
  42. package/dist/commands/serve.js +37 -0
  43. package/dist/commands/sessions-inject.d.ts +14 -0
  44. package/dist/commands/sessions-inject.js +111 -0
  45. package/dist/commands/sessions-picker.d.ts +2 -0
  46. package/dist/commands/sessions-picker.js +24 -3
  47. package/dist/commands/sessions-resume.d.ts +2 -0
  48. package/dist/commands/sessions-resume.js +221 -0
  49. package/dist/commands/sessions-tail.d.ts +10 -0
  50. package/dist/commands/sessions-tail.js +11 -0
  51. package/dist/commands/sessions.d.ts +143 -1
  52. package/dist/commands/sessions.js +628 -80
  53. package/dist/commands/setup.js +4 -2
  54. package/dist/commands/sync.d.ts +3 -1
  55. package/dist/commands/sync.js +156 -4
  56. package/dist/commands/teams.js +217 -0
  57. package/dist/commands/versions.js +2 -4
  58. package/dist/commands/view.d.ts +3 -0
  59. package/dist/commands/view.js +19 -8
  60. package/dist/commands/wallet.d.ts +6 -0
  61. package/dist/commands/wallet.js +22 -5
  62. package/dist/commands/watchdog.d.ts +18 -0
  63. package/dist/commands/watchdog.js +238 -0
  64. package/dist/index.js +82 -32
  65. package/dist/lib/agents.js +30 -9
  66. package/dist/lib/audit/log.d.ts +92 -0
  67. package/dist/lib/audit/log.js +177 -0
  68. package/dist/lib/auto-pull.js +2 -1
  69. package/dist/lib/browser/cdp.js +5 -1
  70. package/dist/lib/browser/chrome.d.ts +10 -0
  71. package/dist/lib/browser/chrome.js +18 -7
  72. package/dist/lib/browser/drivers/ssh.d.ts +8 -0
  73. package/dist/lib/browser/drivers/ssh.js +77 -16
  74. package/dist/lib/browser/har.d.ts +84 -0
  75. package/dist/lib/browser/har.js +77 -0
  76. package/dist/lib/browser/ipc.d.ts +3 -0
  77. package/dist/lib/browser/ipc.js +37 -12
  78. package/dist/lib/browser/profiles.d.ts +1 -1
  79. package/dist/lib/browser/profiles.js +8 -10
  80. package/dist/lib/browser/refs.d.ts +65 -0
  81. package/dist/lib/browser/refs.js +73 -1
  82. package/dist/lib/browser/runtime-state.js +1 -0
  83. package/dist/lib/browser/service.d.ts +38 -2
  84. package/dist/lib/browser/service.js +183 -23
  85. package/dist/lib/browser/types.d.ts +14 -1
  86. package/dist/lib/budget/live-cloud.d.ts +42 -0
  87. package/dist/lib/budget/live-cloud.js +79 -0
  88. package/dist/lib/budget/live-team.d.ts +31 -0
  89. package/dist/lib/budget/live-team.js +115 -0
  90. package/dist/lib/cloud/codex.js +4 -0
  91. package/dist/lib/cloud/rush.d.ts +12 -1
  92. package/dist/lib/cloud/rush.js +13 -3
  93. package/dist/lib/cloud/types.d.ts +9 -0
  94. package/dist/lib/computer/dispatch.d.ts +8 -0
  95. package/dist/lib/computer/dispatch.js +125 -0
  96. package/dist/lib/computer/loop.d.ts +62 -0
  97. package/dist/lib/computer/loop.js +98 -0
  98. package/dist/lib/computer/model.d.ts +44 -0
  99. package/dist/lib/computer/model.js +157 -0
  100. package/dist/lib/concurrency.d.ts +19 -0
  101. package/dist/lib/concurrency.js +33 -0
  102. package/dist/lib/daemon.d.ts +92 -0
  103. package/dist/lib/daemon.js +222 -18
  104. package/dist/lib/devices/connect.d.ts +3 -2
  105. package/dist/lib/devices/connect.js +5 -3
  106. package/dist/lib/devices/registry.d.ts +7 -0
  107. package/dist/lib/devices/registry.js +24 -0
  108. package/dist/lib/devices/tailscale.js +1 -1
  109. package/dist/lib/drift.d.ts +52 -0
  110. package/dist/lib/drift.js +112 -0
  111. package/dist/lib/events.d.ts +10 -3
  112. package/dist/lib/events.js +101 -24
  113. package/dist/lib/exec.d.ts +17 -0
  114. package/dist/lib/exec.js +100 -12
  115. package/dist/lib/git.d.ts +27 -0
  116. package/dist/lib/git.js +56 -1
  117. package/dist/lib/hooks/cache.d.ts +6 -0
  118. package/dist/lib/hooks/cache.js +54 -12
  119. package/dist/lib/hooks.d.ts +27 -0
  120. package/dist/lib/hooks.js +135 -10
  121. package/dist/lib/hosts/dispatch.d.ts +15 -0
  122. package/dist/lib/hosts/dispatch.js +45 -13
  123. package/dist/lib/hosts/logs.d.ts +16 -0
  124. package/dist/lib/hosts/logs.js +74 -0
  125. package/dist/lib/hosts/option.js +1 -1
  126. package/dist/lib/hosts/passthrough.js +3 -1
  127. package/dist/lib/hosts/progress.d.ts +66 -0
  128. package/dist/lib/hosts/progress.js +125 -17
  129. package/dist/lib/hosts/ready.d.ts +51 -7
  130. package/dist/lib/hosts/ready.js +98 -25
  131. package/dist/lib/hosts/reconcile.d.ts +53 -0
  132. package/dist/lib/hosts/reconcile.js +81 -0
  133. package/dist/lib/hosts/registry.d.ts +19 -2
  134. package/dist/lib/hosts/registry.js +58 -2
  135. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  136. package/dist/lib/hosts/remote-cmd.js +70 -1
  137. package/dist/lib/hosts/remote-os.d.ts +17 -0
  138. package/dist/lib/hosts/remote-os.js +30 -0
  139. package/dist/lib/hosts/session-index.d.ts +34 -0
  140. package/dist/lib/hosts/session-index.js +56 -0
  141. package/dist/lib/hosts/tasks.d.ts +22 -0
  142. package/dist/lib/hosts/tasks.js +29 -0
  143. package/dist/lib/lock.d.ts +93 -0
  144. package/dist/lib/lock.js +207 -0
  145. package/dist/lib/loop.js +16 -1
  146. package/dist/lib/machine-id.d.ts +21 -0
  147. package/dist/lib/machine-id.js +26 -0
  148. package/dist/lib/mailbox-target.d.ts +36 -0
  149. package/dist/lib/mailbox-target.js +45 -0
  150. package/dist/lib/mailbox.d.ts +47 -0
  151. package/dist/lib/mailbox.js +194 -0
  152. package/dist/lib/mcp.d.ts +5 -0
  153. package/dist/lib/mcp.js +24 -8
  154. package/dist/lib/migrate.d.ts +19 -0
  155. package/dist/lib/migrate.js +134 -26
  156. package/dist/lib/overdue.js +3 -0
  157. package/dist/lib/permissions.d.ts +4 -0
  158. package/dist/lib/permissions.js +35 -0
  159. package/dist/lib/picker.d.ts +26 -0
  160. package/dist/lib/picker.js +127 -0
  161. package/dist/lib/platform/exec.d.ts +46 -0
  162. package/dist/lib/platform/exec.js +74 -0
  163. package/dist/lib/platform/process.d.ts +31 -0
  164. package/dist/lib/platform/process.js +34 -1
  165. package/dist/lib/platform/winpath.js +2 -0
  166. package/dist/lib/plugin-marketplace.d.ts +30 -0
  167. package/dist/lib/plugin-marketplace.js +215 -2
  168. package/dist/lib/plugins.d.ts +5 -0
  169. package/dist/lib/plugins.js +61 -10
  170. package/dist/lib/profiles.d.ts +25 -0
  171. package/dist/lib/profiles.js +22 -6
  172. package/dist/lib/pty-client.js +2 -1
  173. package/dist/lib/rotate.d.ts +61 -0
  174. package/dist/lib/rotate.js +52 -0
  175. package/dist/lib/routines.d.ts +40 -2
  176. package/dist/lib/routines.js +66 -8
  177. package/dist/lib/runner.d.ts +11 -2
  178. package/dist/lib/runner.js +49 -7
  179. package/dist/lib/scheduler.js +6 -1
  180. package/dist/lib/secrets/bundles.d.ts +60 -4
  181. package/dist/lib/secrets/bundles.js +131 -12
  182. package/dist/lib/secrets/filestore.d.ts +3 -0
  183. package/dist/lib/secrets/filestore.js +42 -16
  184. package/dist/lib/secrets/index.d.ts +43 -2
  185. package/dist/lib/secrets/index.js +102 -3
  186. package/dist/lib/secrets/mcp.d.ts +93 -0
  187. package/dist/lib/secrets/mcp.js +205 -0
  188. package/dist/lib/secrets/remote.js +12 -5
  189. package/dist/lib/secrets/sync.js +83 -4
  190. package/dist/lib/secrets/windows.js +14 -3
  191. package/dist/lib/serve/data.d.ts +81 -0
  192. package/dist/lib/serve/data.js +91 -0
  193. package/dist/lib/serve/page.d.ts +7 -0
  194. package/dist/lib/serve/page.js +140 -0
  195. package/dist/lib/serve/server.d.ts +46 -0
  196. package/dist/lib/serve/server.js +115 -0
  197. package/dist/lib/session/active.d.ts +61 -0
  198. package/dist/lib/session/active.js +196 -17
  199. package/dist/lib/session/discover.d.ts +37 -0
  200. package/dist/lib/session/discover.js +204 -27
  201. package/dist/lib/session/inject.d.ts +18 -0
  202. package/dist/lib/session/inject.js +21 -0
  203. package/dist/lib/session/parse.d.ts +6 -0
  204. package/dist/lib/session/parse.js +286 -2
  205. package/dist/lib/session/pid-registry.d.ts +22 -0
  206. package/dist/lib/session/pid-registry.js +104 -0
  207. package/dist/lib/session/provenance.d.ts +14 -2
  208. package/dist/lib/session/provenance.js +39 -8
  209. package/dist/lib/session/remote-active.d.ts +26 -0
  210. package/dist/lib/session/remote-active.js +153 -0
  211. package/dist/lib/session/remote-list.d.ts +51 -0
  212. package/dist/lib/session/remote-list.js +213 -0
  213. package/dist/lib/session/remote.d.ts +7 -1
  214. package/dist/lib/session/remote.js +18 -8
  215. package/dist/lib/session/render.js +1 -1
  216. package/dist/lib/session/sync/config.d.ts +1 -7
  217. package/dist/lib/session/sync/config.js +4 -11
  218. package/dist/lib/session/types.d.ts +18 -1
  219. package/dist/lib/session/types.js +1 -1
  220. package/dist/lib/shims.d.ts +44 -6
  221. package/dist/lib/shims.js +143 -57
  222. package/dist/lib/ssh-exec.d.ts +26 -3
  223. package/dist/lib/ssh-exec.js +47 -3
  224. package/dist/lib/ssh-tunnel.d.ts +24 -5
  225. package/dist/lib/ssh-tunnel.js +62 -63
  226. package/dist/lib/staleness/writers/hooks.js +1 -1
  227. package/dist/lib/startup/command-registry.d.ts +8 -0
  228. package/dist/lib/startup/command-registry.js +17 -1
  229. package/dist/lib/state.d.ts +13 -0
  230. package/dist/lib/state.js +103 -9
  231. package/dist/lib/sync-umbrella.d.ts +14 -7
  232. package/dist/lib/sync-umbrella.js +17 -9
  233. package/dist/lib/teams/forEach.d.ts +110 -0
  234. package/dist/lib/teams/forEach.js +186 -0
  235. package/dist/lib/teams/index.d.ts +1 -0
  236. package/dist/lib/teams/index.js +1 -0
  237. package/dist/lib/teams/pr-watch.d.ts +226 -0
  238. package/dist/lib/teams/pr-watch.js +371 -0
  239. package/dist/lib/teams/registry.js +25 -9
  240. package/dist/lib/teams/supervisor.d.ts +14 -1
  241. package/dist/lib/teams/supervisor.js +19 -0
  242. package/dist/lib/teams/worktree.d.ts +9 -0
  243. package/dist/lib/teams/worktree.js +32 -0
  244. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  245. package/dist/lib/terminal/backends/ghostty.js +69 -0
  246. package/dist/lib/terminal/backends/index.d.ts +19 -0
  247. package/dist/lib/terminal/backends/index.js +31 -0
  248. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  249. package/dist/lib/terminal/backends/iterm.js +62 -0
  250. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  251. package/dist/lib/terminal/backends/tmux.js +23 -0
  252. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  253. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  254. package/dist/lib/terminal/engine.d.ts +39 -0
  255. package/dist/lib/terminal/engine.js +54 -0
  256. package/dist/lib/terminal/index.d.ts +17 -0
  257. package/dist/lib/terminal/index.js +8 -0
  258. package/dist/lib/terminal/inject.d.ts +204 -0
  259. package/dist/lib/terminal/inject.js +247 -0
  260. package/dist/lib/terminal/policy.d.ts +11 -0
  261. package/dist/lib/terminal/policy.js +11 -0
  262. package/dist/lib/terminal/quote.d.ts +11 -0
  263. package/dist/lib/terminal/quote.js +13 -0
  264. package/dist/lib/terminal/resolve.d.ts +64 -0
  265. package/dist/lib/terminal/resolve.js +90 -0
  266. package/dist/lib/terminal/shell.d.ts +6 -0
  267. package/dist/lib/terminal/shell.js +23 -0
  268. package/dist/lib/terminal/transport.d.ts +15 -0
  269. package/dist/lib/terminal/transport.js +40 -0
  270. package/dist/lib/terminal/types.d.ts +59 -0
  271. package/dist/lib/terminal/types.js +13 -0
  272. package/dist/lib/triggers/webhook.d.ts +85 -0
  273. package/dist/lib/triggers/webhook.js +141 -0
  274. package/dist/lib/types.d.ts +7 -0
  275. package/dist/lib/versions.d.ts +30 -3
  276. package/dist/lib/versions.js +129 -18
  277. package/dist/lib/watchdog/index.d.ts +3 -0
  278. package/dist/lib/watchdog/index.js +5 -0
  279. package/dist/lib/watchdog/read.d.ts +35 -0
  280. package/dist/lib/watchdog/read.js +149 -0
  281. package/dist/lib/watchdog/runner.d.ts +127 -0
  282. package/dist/lib/watchdog/runner.js +322 -0
  283. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  284. package/dist/lib/watchdog/watchdog.js +166 -0
  285. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  286. package/dist/lib/watchdog/watchdogTail.js +154 -0
  287. package/dist/lib/whats-new.d.ts +9 -0
  288. package/dist/lib/whats-new.js +35 -0
  289. package/dist/lib/workflows.d.ts +166 -0
  290. package/dist/lib/workflows.js +193 -0
  291. package/package.json +5 -4
@@ -16,6 +16,7 @@ import { prepareJobHome, buildSpawnEnv } from './sandbox.js';
16
16
  import { resolveModel, buildReasoningFlags } from './models.js';
17
17
  import { createTimer, maybeRotate, redactPrompt } from './events.js';
18
18
  import { normalizeMode } from './exec.js';
19
+ import { backgroundSpawnOptions } from './platform/process.js';
19
20
  /** CLI command templates per agent, with {prompt} as a placeholder. */
20
21
  const AGENT_COMMANDS = {
21
22
  claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
@@ -152,8 +153,16 @@ function appendModelAndReasoning(cmd, config) {
152
153
  function generateRunId() {
153
154
  return new Date().toISOString().replace(/[:.]/g, '-');
154
155
  }
155
- /** Execute a job synchronously (waits for completion or timeout before resolving). */
156
- export async function executeJob(config) {
156
+ /**
157
+ * Execute a job synchronously (waits for completion or timeout before resolving).
158
+ *
159
+ * When `config.loop` is set the job is routed through the loop driver (`runLoop`
160
+ * from loop.ts) instead of a single spawn — same driver as `agents run --loop` and
161
+ * workflow `loop:` blocks (issue #400). The optional `deps` parameter provides
162
+ * injectable seams (runIteration, sleep, writeCheckpoint) used by tests; production
163
+ * callers omit it and get the defaults.
164
+ */
165
+ export async function executeJob(config, deps) {
157
166
  maybeRotate();
158
167
  const timer = createTimer('agent.run', {
159
168
  agent: config.agent,
@@ -164,14 +173,11 @@ export async function executeJob(config) {
164
173
  schedule: config.schedule,
165
174
  });
166
175
  const resolvedPrompt = resolveJobPrompt(config);
167
- const cmd = buildJobCommand(config, resolvedPrompt);
168
176
  const useSandbox = config.sandbox !== false;
169
177
  const overlayHome = useSandbox ? prepareJobHome(config) : undefined;
170
178
  const runId = generateRunId();
171
179
  const runDir = getRunDir(config.name, runId);
172
180
  fs.mkdirSync(runDir, { recursive: true });
173
- const stdoutPath = path.join(runDir, 'stdout.log');
174
- const stdoutFd = fs.openSync(stdoutPath, 'w', 0o600);
175
181
  let spawnEnv = useSandbox ? buildSpawnEnv(overlayHome) : { ...process.env };
176
182
  if (config.timezone) {
177
183
  spawnEnv.TZ = config.timezone;
@@ -192,10 +198,46 @@ export async function executeJob(config) {
192
198
  };
193
199
  writeRunMeta(meta);
194
200
  const timeoutMs = parseTimeout(config.timeout) || 10 * 60 * 1000;
201
+ // Loop path: delegate to runLoop (same driver as `agents run --loop` / workflow loop:).
202
+ if (config.loop) {
203
+ const execOptions = {
204
+ agent: effectiveAgent,
205
+ version: config.version,
206
+ prompt: resolvedPrompt,
207
+ mode: normalizeMode(config.mode),
208
+ effort: config.effort,
209
+ env: spawnEnv,
210
+ json: true,
211
+ headless: true,
212
+ ...(config.config?.model ? { model: config.config.model } : {}),
213
+ ...(config.allow?.dirs ? {
214
+ addDirs: config.allow.dirs
215
+ .filter((d) => !d.startsWith('-'))
216
+ .map((d) => d.replace(/^~/, os.homedir())),
217
+ } : {}),
218
+ };
219
+ const { runLoop } = await import('./loop.js');
220
+ const loopResult = await runLoop(execOptions, config.loop, {
221
+ runId,
222
+ runDir,
223
+ agent: effectiveAgent,
224
+ version: config.version,
225
+ }, deps);
226
+ meta.status = loopResult.stoppedBy === 'error' ? 'failed' : 'completed';
227
+ meta.completedAt = new Date().toISOString();
228
+ meta.exitCode = loopResult.stoppedBy === 'error' ? 1 : 0;
229
+ writeRunMeta(meta);
230
+ timer.end({ status: meta.status, exitCode: meta.exitCode ?? undefined, runId });
231
+ return { meta, reportPath: null };
232
+ }
233
+ // Single-shot path (no loop): build the command, open a log file, and spawn once.
234
+ const cmd = buildJobCommand(config, resolvedPrompt);
235
+ const stdoutPath = path.join(runDir, 'stdout.log');
236
+ const stdoutFd = fs.openSync(stdoutPath, 'w', 0o600);
195
237
  return new Promise((resolve) => {
196
238
  const child = spawn(cmd[0], cmd.slice(1), {
197
239
  stdio: ['ignore', stdoutFd, stdoutFd],
198
- detached: true,
240
+ ...backgroundSpawnOptions({ fdStdio: true }),
199
241
  env: spawnEnv,
200
242
  });
201
243
  // Mark startup time (time from function call to process spawn)
@@ -290,7 +332,7 @@ export async function executeJobDetached(config) {
290
332
  };
291
333
  const child = spawn(cmd[0], cmd.slice(1), {
292
334
  stdio: ['ignore', stdoutFd, stdoutFd],
293
- detached: true,
335
+ ...backgroundSpawnOptions({ fdStdio: true }),
294
336
  env: spawnEnv,
295
337
  });
296
338
  child.unref();
@@ -17,12 +17,17 @@ export class JobScheduler {
17
17
  loadAll() {
18
18
  const configs = listJobs();
19
19
  for (const config of configs) {
20
- if (config.enabled) {
20
+ // Trigger-only jobs (no cron schedule) fire via the webhook receiver,
21
+ // not the cron loop — skip them here.
22
+ if (config.enabled && config.schedule) {
21
23
  this.schedule(config);
22
24
  }
23
25
  }
24
26
  }
25
27
  schedule(config) {
28
+ // A schedule-less (trigger-only) job has nothing to hand to croner.
29
+ if (!config.schedule)
30
+ return;
26
31
  this.unschedule(config.name);
27
32
  // catch: true — a throw from one job's callback should not kill the
28
33
  // whole cron loop. Each invocation of onTrigger is already wrapped in
@@ -48,13 +48,20 @@ export interface VarMeta {
48
48
  * - `always`: asks every time. Never auto-held — only an explicit `agents
49
49
  * secrets unlock` ever holds it; every other read pops Touch ID. Opt a
50
50
  * high-value bundle into this when you want to confirm every single read.
51
+ * - `never`: stored WITHOUT the biometry access control — reads are fully
52
+ * silent (no Touch ID, no broker). The least-safe tier: any code running as
53
+ * the user reads it with no user-presence check. Reserved for low-sensitivity,
54
+ * automation-only credentials. Writing a `never` item needs the signed helper's
55
+ * `set-no-acl` path (see keychain-helper.swift); an older pinned helper rejects
56
+ * it loudly rather than silently downgrading to `always`.
51
57
  *
52
58
  * The default is configurable via `secrets.policy` in agents.yaml. Stored on disk
53
59
  * under the legacy `tier` key (`session` == `daily`, `biometry` == explicit
54
- * `always`, absent == inherit the default) so bundles stay readable across mixed
55
- * CLI versions on synced machines. The user-facing vocabulary is `policy`/`always`/`daily`.
60
+ * `always`, `none` == `never`, absent == inherit the default) so bundles stay
61
+ * readable across mixed CLI versions on synced machines. The user-facing
62
+ * vocabulary is `policy`/`always`/`daily`/`never`.
56
63
  */
57
- export type SecretsPolicy = 'always' | 'daily';
64
+ export type SecretsPolicy = 'always' | 'daily' | 'never';
58
65
  /** A named set of environment variable definitions backed by various secret providers. */
59
66
  export interface SecretsBundle {
60
67
  name: string;
@@ -131,7 +138,54 @@ export interface ResolveBundleOptions {
131
138
  * needs live values. Also honored via AGENTS_SECRETS_NO_AGENT=1.
132
139
  */
133
140
  noAgent?: boolean;
141
+ /**
142
+ * Inject only this subset of keys from the bundle. Keys not in this list are
143
+ * silently excluded from the returned env map. An error is thrown if any
144
+ * requested key is absent from the bundle (fail-loud, never silent skip).
145
+ * When absent or empty, all keys are injected (original behaviour).
146
+ */
147
+ keys?: string[];
148
+ /**
149
+ * When true, skip the pre-run expiry check and inject keys even if their
150
+ * `expires` date is in the past. By default any expired key (or a key whose
151
+ * bundle-level expiry has passed) aborts the run before Touch ID is popped.
152
+ */
153
+ allowExpired?: boolean;
134
154
  }
155
+ /**
156
+ * Apply the --keys subset + expiry gate to an already-resolved snapshot from
157
+ * the secrets-agent fast-path. The agent stores the FULL bundle env, so a
158
+ * naive fast-path return would silently defeat --keys and inject expired
159
+ * values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
160
+ * `readAndResolveBundleEnv` and returns a new env whose keys match the subset.
161
+ *
162
+ * Exported for tests; production callers reach it via the fast-path branch in
163
+ * `readAndResolveBundleEnv`.
164
+ */
165
+ export declare function filterAgentHitBySubsetAndExpiry(hit: {
166
+ bundle: SecretsBundle;
167
+ env: Record<string, string>;
168
+ }, opts: ResolveBundleOptions): {
169
+ bundle: SecretsBundle;
170
+ env: Record<string, string>;
171
+ };
172
+ /**
173
+ * Guard for remote-bundle callers (`bundle@host` / `--host`) — the SSH
174
+ * resolver in `remoteResolveEnv` does not thread --keys or --allow-expired
175
+ * yet. Silently applying them would inject the full remote env or an expired
176
+ * value, defeating the least-privilege intent, so we fail loud.
177
+ *
178
+ * Exported so `agents run --secrets bundle@host` and `agents secrets exec
179
+ * --host` share the exact same error text; the tests exercise this helper
180
+ * directly instead of driving the whole CLI.
181
+ */
182
+ export declare function assertRemoteBundleFlagsUnsupported(bundleName: string, host: string, opts: {
183
+ keys?: string[];
184
+ allowExpired?: boolean;
185
+ }, flagLabels: {
186
+ keysFlag: string;
187
+ allowExpiredFlag: string;
188
+ }): void;
135
189
  export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveBundleOptions): Record<string, string>;
136
190
  /**
137
191
  * Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
@@ -193,7 +247,9 @@ export declare function renameBundle(oldName: string, newName: string, opts?: Re
193
247
  * `import` / `remove` / `delete`. Pass the bundle's resolved backend
194
248
  * (`bundle.backend ?? 'keychain'`).
195
249
  */
196
- export declare function bundleItemStore(backend: SecretsBackend | undefined): {
250
+ export declare function bundleItemStore(backend: SecretsBackend | undefined, opts?: {
251
+ noAcl?: boolean;
252
+ }): {
197
253
  set(item: string, value: string): void;
198
254
  delete(item: string): boolean;
199
255
  get(item: string): string;
@@ -257,6 +257,8 @@ function parsePolicy(raw) {
257
257
  return 'daily';
258
258
  if (raw === 'always' || raw === 'biometry')
259
259
  return 'always';
260
+ if (raw === 'never' || raw === 'none')
261
+ return 'never';
260
262
  return undefined;
261
263
  }
262
264
  /** The default prompt policy applied to bundles without an explicit per-bundle
@@ -314,9 +316,14 @@ export function writeBundle(bundle) {
314
316
  backend: backend === 'file' ? 'file' : undefined,
315
317
  // Wire format: persist the policy under the legacy `tier` token so older CLI
316
318
  // versions on other synced machines keep reading it — `daily`⇒`session`,
317
- // explicit `always`⇒`biometry`. An absent policy omits the token entirely
318
- // and resolves to the configured default (`daily`) on read.
319
- tier: bundle.policy === 'daily' ? 'session' : bundle.policy === 'always' ? 'biometry' : undefined,
319
+ // explicit `always`⇒`biometry`, `never`⇒`none`. An absent policy omits the
320
+ // token entirely and resolves to the configured default (`daily`) on read.
321
+ // An older CLI that doesn't know `none` reads it as undefined and falls back
322
+ // to its own default — safe, since it also lacks the no-ACL write path.
323
+ tier: bundle.policy === 'daily' ? 'session'
324
+ : bundle.policy === 'always' ? 'biometry'
325
+ : bundle.policy === 'never' ? 'none'
326
+ : undefined,
320
327
  created_at: bundle.created_at,
321
328
  updated_at: bundle.updated_at,
322
329
  last_used: bundle.last_used,
@@ -324,7 +331,11 @@ export function writeBundle(bundle) {
324
331
  meta,
325
332
  };
326
333
  const json = JSON.stringify(payload);
327
- itemStore(backend).set(bundleMetaItem(bundle.name), json);
334
+ // A `never` bundle's metadata is stored without the biometry ACL too, so
335
+ // `view` and the metadata half of a read resolve silently — the whole point
336
+ // of the tier. On an un-updated pinned helper this write fails loudly (the
337
+ // no-ACL command is missing) rather than silently landing an ACL'd item.
338
+ itemStore(backend).set(bundleMetaItem(bundle.name), json, { noAcl: bundle.policy === 'never' });
328
339
  emit('secrets.set', { bundle: bundle.name });
329
340
  }
330
341
  export function deleteBundle(name) {
@@ -504,6 +515,87 @@ function stampLastUsed(bundle) {
504
515
  // Swallow — telemetry must never block secret resolution.
505
516
  }
506
517
  }
518
+ /**
519
+ * Abort if any of the selected keys has an `expires` date in the past.
520
+ * Bundle-level expiry is not a concept today (expiry is per-key via `meta`),
521
+ * so we iterate only the per-key meta entries.
522
+ */
523
+ function assertNotExpired(bundle, selectedKeys, allowExpired) {
524
+ if (allowExpired)
525
+ return;
526
+ if (!bundle.meta)
527
+ return;
528
+ const now = Date.now();
529
+ for (const key of selectedKeys) {
530
+ const meta = bundle.meta[key];
531
+ if (!meta?.expires)
532
+ continue;
533
+ // expires is 'YYYY-MM-DD'; treat as end-of-day UTC.
534
+ const expiry = new Date(meta.expires + 'T23:59:59Z').getTime();
535
+ if (expiry < now) {
536
+ throw new Error(`Bundle '${bundle.name}' key '${key}' expired on ${meta.expires}. ` +
537
+ `Rotate it with: agents secrets rotate ${bundle.name} ${key}` +
538
+ ` (or pass --allow-expired to skip this check).`);
539
+ }
540
+ }
541
+ }
542
+ /**
543
+ * Resolve the requested key subset against a bundle's `vars` map. Throws a
544
+ * fail-loud error listing available keys if any requested key is absent. When
545
+ * `requested` is undefined or empty, every key in the bundle is selected.
546
+ */
547
+ function selectRequestedKeys(bundle, requested) {
548
+ const req = requested?.length ? requested : undefined;
549
+ if (req) {
550
+ const missing = req.filter((k) => !(k in bundle.vars));
551
+ if (missing.length > 0) {
552
+ const available = Object.keys(bundle.vars).join(', ') || '(none)';
553
+ throw new Error(`Bundle '${bundle.name}' does not contain key(s): ${missing.join(', ')}. Available: ${available}.`);
554
+ }
555
+ }
556
+ return new Set(req ?? Object.keys(bundle.vars));
557
+ }
558
+ /**
559
+ * Apply the --keys subset + expiry gate to an already-resolved snapshot from
560
+ * the secrets-agent fast-path. The agent stores the FULL bundle env, so a
561
+ * naive fast-path return would silently defeat --keys and inject expired
562
+ * values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
563
+ * `readAndResolveBundleEnv` and returns a new env whose keys match the subset.
564
+ *
565
+ * Exported for tests; production callers reach it via the fast-path branch in
566
+ * `readAndResolveBundleEnv`.
567
+ */
568
+ export function filterAgentHitBySubsetAndExpiry(hit, opts) {
569
+ const selectedKeys = selectRequestedKeys(hit.bundle, opts.keys);
570
+ assertNotExpired(hit.bundle, [...selectedKeys], opts.allowExpired ?? false);
571
+ // When no subset was requested, return the cached env untouched — same
572
+ // reference the agent handed back, so no per-call allocation on the hot path.
573
+ if (!opts.keys?.length)
574
+ return hit;
575
+ const env = {};
576
+ for (const key of selectedKeys) {
577
+ if (key in hit.env)
578
+ env[key] = hit.env[key];
579
+ }
580
+ return { bundle: hit.bundle, env };
581
+ }
582
+ /**
583
+ * Guard for remote-bundle callers (`bundle@host` / `--host`) — the SSH
584
+ * resolver in `remoteResolveEnv` does not thread --keys or --allow-expired
585
+ * yet. Silently applying them would inject the full remote env or an expired
586
+ * value, defeating the least-privilege intent, so we fail loud.
587
+ *
588
+ * Exported so `agents run --secrets bundle@host` and `agents secrets exec
589
+ * --host` share the exact same error text; the tests exercise this helper
590
+ * directly instead of driving the whole CLI.
591
+ */
592
+ export function assertRemoteBundleFlagsUnsupported(bundleName, host, opts, flagLabels) {
593
+ const hasKeys = Array.isArray(opts.keys) && opts.keys.length > 0;
594
+ if (!hasKeys && !opts.allowExpired)
595
+ return;
596
+ throw new Error(`Bundle '${bundleName}@${host}': ${flagLabels.keysFlag} and ${flagLabels.allowExpiredFlag} are not supported for remote (bundle@host) bundles yet. ` +
597
+ `Drop the flag or resolve the bundle locally.`);
598
+ }
507
599
  // Walk the bundle and produce a flat env map. Every keychain: ref is gathered
508
600
  // into a single batch read so macOS shows ONE Touch ID prompt for the whole
509
601
  // bundle — including the metadata fetch that already happened in readBundle
@@ -513,9 +605,14 @@ function stampLastUsed(bundle) {
513
605
  // resolved inline and never reach the keychain.
514
606
  export function resolveBundleEnv(bundle, _opts = {}) {
515
607
  stampLastUsed(bundle);
608
+ // Key-subset validation and expiry pre-check.
609
+ const selectedKeys = selectRequestedKeys(bundle, _opts.keys);
610
+ assertNotExpired(bundle, [...selectedKeys], _opts.allowExpired ?? false);
516
611
  const parsedByKey = new Map();
517
612
  const keychainItemsToFetch = [];
518
613
  for (const [key, raw] of Object.entries(bundle.vars)) {
614
+ if (!selectedKeys.has(key))
615
+ continue;
519
616
  const parsed = parseBundleValue(raw);
520
617
  parsedByKey.set(key, parsed);
521
618
  if ('ref' in parsed && parsed.ref.provider === 'keychain') {
@@ -528,6 +625,8 @@ export function resolveBundleEnv(bundle, _opts = {}) {
528
625
  : new Map();
529
626
  const env = {};
530
627
  for (const [key, raw] of Object.entries(bundle.vars)) {
628
+ if (!selectedKeys.has(key))
629
+ continue;
531
630
  const parsed = parsedByKey.get(key);
532
631
  if ('literal' in parsed) {
533
632
  env[key] = parsed.literal;
@@ -582,15 +681,20 @@ export function readAndResolveBundleEnv(name, opts = {}) {
582
681
  if (backend === 'keychain' && !opts.noAgent && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
583
682
  const hit = agentGetSync(name);
584
683
  if (hit) {
585
- stampLastUsed(hit.bundle);
684
+ // The agent stores the FULL bundle env. Apply the same subset filter and
685
+ // expiry gate as the slow path — without this, `--secrets-keys X` would
686
+ // silently inject every key and an expired key would flow through after
687
+ // the first cache-populating run.
688
+ const filtered = filterAgentHitBySubsetAndExpiry(hit, opts);
689
+ stampLastUsed(filtered.bundle);
586
690
  emit('secrets.get', {
587
691
  bundle: name,
588
692
  caller: opts.caller,
589
693
  status: 'success',
590
694
  source: 'agent',
591
- keyCount: Object.keys(hit.env).length,
695
+ keyCount: Object.keys(filtered.env).length,
592
696
  });
593
- return hit;
697
+ return filtered;
594
698
  }
595
699
  }
596
700
  if (backend === 'file')
@@ -651,11 +755,16 @@ export function readAndResolveBundleEnv(name, opts = {}) {
651
755
  for (const key of Object.keys(bundle.vars)) {
652
756
  validateEnvKey(key);
653
757
  }
758
+ // Key-subset validation and expiry pre-check (mirrors resolveBundleEnv logic).
759
+ const selectedKeys = selectRequestedKeys(bundle, opts.keys);
760
+ assertNotExpired(bundle, [...selectedKeys], opts.allowExpired ?? false);
654
761
  stampLastUsed(bundle);
655
762
  const parsedByKey = new Map();
656
763
  const keychainKeys = [];
657
764
  const kindCounts = {};
658
765
  for (const [key, raw] of Object.entries(bundle.vars)) {
766
+ if (!selectedKeys.has(key))
767
+ continue;
659
768
  const p = parseBundleValue(raw);
660
769
  parsedByKey.set(key, p);
661
770
  const kind = 'literal' in p ? 'literal' : p.ref.provider;
@@ -664,7 +773,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
664
773
  keychainKeys.push(key);
665
774
  }
666
775
  }
667
- const keys = Object.keys(bundle.vars).sort();
776
+ const keys = [...selectedKeys].sort();
668
777
  keychainKeys.sort();
669
778
  const emitReadAudit = (status, err) => {
670
779
  emit('secrets.get', {
@@ -681,6 +790,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
681
790
  try {
682
791
  const env = {};
683
792
  for (const [key] of Object.entries(bundle.vars)) {
793
+ if (!selectedKeys.has(key))
794
+ continue;
684
795
  const p = parsedByKey.get(key);
685
796
  if ('literal' in p) {
686
797
  env[key] = p.literal;
@@ -749,7 +860,7 @@ export function rotateBundleSecret(bundle, key, opts) {
749
860
  }
750
861
  const shortId = raw.slice('keychain:'.length);
751
862
  const item = secretsKeychainItem(bundle.name, shortId);
752
- itemStore(bundle.backend ?? 'keychain').set(item, opts.newValue);
863
+ itemStore(bundle.backend ?? 'keychain').set(item, opts.newValue, { noAcl: bundlePolicy(bundle) === 'never' });
753
864
  if (opts.clearMeta) {
754
865
  if (bundle.meta)
755
866
  delete bundle.meta[key];
@@ -817,7 +928,7 @@ export function renameBundle(oldName, newName, opts = {}) {
817
928
  const shortId = raw.slice('keychain:'.length);
818
929
  const newItem = secretsKeychainItem(newName, shortId);
819
930
  const value = store.get(oldItem);
820
- store.set(newItem, value);
931
+ store.set(newItem, value, { noAcl: bundlePolicy(source) === 'never' });
821
932
  }
822
933
  // writeBundle preserves source.created_at, refreshes updated_at, and keeps
823
934
  // the source backend (spread carries source.backend).
@@ -837,8 +948,16 @@ export function renameBundle(oldName, newName, opts = {}) {
837
948
  * `import` / `remove` / `delete`. Pass the bundle's resolved backend
838
949
  * (`bundle.backend ?? 'keychain'`).
839
950
  */
840
- export function bundleItemStore(backend) {
841
- return itemStore(backend ?? 'keychain');
951
+ export function bundleItemStore(backend, opts) {
952
+ const store = itemStore(backend ?? 'keychain');
953
+ // `never`-policy bundles write their per-key values without the biometry ACL
954
+ // (same rationale as the metadata write in writeBundle). Wrap `set` so every
955
+ // value the add/import paths write inherits the no-ACL flag; reads, deletes,
956
+ // and existence checks are ACL-independent and pass through untouched.
957
+ if (opts?.noAcl) {
958
+ return { ...store, set: (item, value) => store.set(item, value, { noAcl: true }) };
959
+ }
960
+ return store;
842
961
  }
843
962
  // Iterate all keychain-backed keys in a bundle for cleanup on rm/unset.
844
963
  export function keychainItemsForBundle(bundle) {
@@ -74,9 +74,12 @@ export declare const fileStore: {
74
74
  /** File-only KeychainBackend (exported for tests; the Linux backend uses these
75
75
  * ops with auto-provision allowed). */
76
76
  export declare const fileBackend: KeychainBackend;
77
+ /** Resolved passphrase directory (exported for integration tests). */
78
+ export declare function resolvePassphraseDir(): string;
77
79
  /** Test-only: reset module state (file dir + cached passphrase). */
78
80
  export declare function _resetFileStoreForTest(opts?: {
79
81
  fileDir?: string | null;
82
+ passphraseDir?: string | null;
80
83
  passphrase?: string | null;
81
84
  }): void;
82
85
  export {};
@@ -28,6 +28,7 @@ import * as path from 'path';
28
28
  import { encodePwshBase64 } from '../pwsh.js';
29
29
  // ---------- file store location ----------
30
30
  let fileDirOverride = null;
31
+ let passphraseDirOverride = null;
31
32
  let cachedPassphrase = null;
32
33
  let warnedAutoPassphrase = false;
33
34
  export function fileDir() {
@@ -106,29 +107,40 @@ function readPassphraseFromTty() {
106
107
  fs.closeSync(fd);
107
108
  }
108
109
  }
109
- /** Path of the auto-provisioned machine-local passphrase. Lives alongside the
110
- * encrypted items but is never itself an item (no `.enc` suffix, so it's
111
- * excluded from list/has/get and from fileFallbackPreviouslyActivated). */
110
+ /**
111
+ * Directory for the auto-provisioned machine-local passphrase. Kept outside
112
+ * `fileDir()` so a scan of the encrypted store never co-locates key + ciphertext.
113
+ */
114
+ function passphraseDir() {
115
+ return passphraseDirOverride ?? path.join(os.homedir(), '.agents', '.secrets-key');
116
+ }
117
+ function ensurePassphraseDir() {
118
+ fs.mkdirSync(passphraseDir(), { recursive: true, mode: 0o700 });
119
+ }
120
+ /** Path of the auto-provisioned machine-local passphrase (not an `.enc` item). */
112
121
  function passphraseFilePath() {
122
+ return path.join(passphraseDir(), 'passphrase');
123
+ }
124
+ /** Legacy co-located path — read-only for machines provisioned before #479. */
125
+ function legacyPassphraseFilePath() {
113
126
  return path.join(fileDir(), '.passphrase');
114
127
  }
115
128
  /** True if a machine-local passphrase has already been provisioned. */
116
129
  export function machinePassphraseExists() {
117
- try {
118
- return fs.readFileSync(passphraseFilePath(), 'utf8').trim().length > 0;
119
- }
120
- catch {
121
- return false;
122
- }
130
+ return readMachinePassphrase() !== null;
123
131
  }
124
132
  function readMachinePassphrase() {
125
- try {
126
- const p = fs.readFileSync(passphraseFilePath(), 'utf8').trim();
127
- return p.length > 0 ? p : null;
128
- }
129
- catch {
130
- return null;
133
+ for (const fp of [passphraseFilePath(), legacyPassphraseFilePath()]) {
134
+ try {
135
+ const p = fs.readFileSync(fp, 'utf8').trim();
136
+ if (p.length > 0)
137
+ return p;
138
+ }
139
+ catch {
140
+ // try next location
141
+ }
131
142
  }
143
+ return null;
132
144
  }
133
145
  /**
134
146
  * Provision (or read back) a stable machine-local passphrase for the encrypted
@@ -146,7 +158,7 @@ function provisionMachinePassphrase() {
146
158
  const existing = readMachinePassphrase();
147
159
  if (existing)
148
160
  return existing;
149
- ensureFileDir();
161
+ ensurePassphraseDir();
150
162
  const generated = randomBytes(32).toString('base64');
151
163
  const fp = passphraseFilePath();
152
164
  try {
@@ -317,9 +329,23 @@ export const fileBackend = {
317
329
  delete: fileDelete,
318
330
  list: fileList,
319
331
  };
332
+ /** Resolved passphrase directory (exported for integration tests). */
333
+ export function resolvePassphraseDir() {
334
+ return passphraseDir();
335
+ }
320
336
  /** Test-only: reset module state (file dir + cached passphrase). */
321
337
  export function _resetFileStoreForTest(opts = {}) {
322
338
  fileDirOverride = opts.fileDir ?? null;
339
+ if (opts.passphraseDir !== undefined) {
340
+ passphraseDirOverride = opts.passphraseDir;
341
+ }
342
+ else if (opts.fileDir) {
343
+ // Hermetic sibling when only the store dir is overridden (linux.test.ts).
344
+ passphraseDirOverride = path.resolve(opts.fileDir, '..', `${path.basename(opts.fileDir)}-key`);
345
+ }
346
+ else {
347
+ passphraseDirOverride = null;
348
+ }
323
349
  cachedPassphrase = opts.passphrase ?? null;
324
350
  warnedAutoPassphrase = false;
325
351
  }
@@ -103,8 +103,16 @@ export declare function getKeychainToken(item: string): string;
103
103
  * lookups — no biometric prompt path on those platforms.
104
104
  */
105
105
  export declare function getKeychainTokens(items: string[]): Map<string, string>;
106
- /** Store or update a secret value in the keychain/keyring. Device-local; biometry-gated on macOS. */
107
- export declare function setKeychainToken(item: string, value: string): void;
106
+ /** Store or update a secret value in the keychain/keyring. Device-local;
107
+ * biometry-gated on macOS. `opts.noAcl` (the `never` prompt-policy) writes our
108
+ * item WITHOUT the biometry access control so later reads are fully silent — it
109
+ * routes through the signed helper's `set-no-acl` path. A pinned helper that
110
+ * predates that path rejects the unknown command (exit 2) and this throws,
111
+ * rather than silently falling back to an ACL'd `set` (which would behave like
112
+ * `always`). Ignored by the Linux/Windows/test backends, which have no ACL. */
113
+ export declare function setKeychainToken(item: string, value: string, opts?: {
114
+ noAcl?: boolean;
115
+ }): void;
108
116
  /** Delete a keychain/keyring item. Returns true if it existed. Never prompts for biometry. */
109
117
  export declare function deleteKeychainToken(item: string): boolean;
110
118
  /**
@@ -140,6 +148,39 @@ export declare function listLegacyKeychainItems(prefix: string): string[];
140
148
  * concept, so the call is a no-op there.
141
149
  */
142
150
  export declare function migrateKeychainItem(item: string): boolean;
151
+ /**
152
+ * Enumerate data-protection items whose service starts with `prefix` that live
153
+ * under a NON-concrete access group — pre-#279 "orphans" filed under the implicit
154
+ * default group (the literal `2HTP252L87.*`) that the pinned-group queries can't
155
+ * see. Attributes only: never decrypts, never prompts. macOS only — Linux/Windows
156
+ * and the test backend have no access-group concept, so this returns [].
157
+ */
158
+ export declare function listOrphanedKeychainItems(prefix: string): string[];
159
+ /** Outcome of re-homing one orphaned keychain item. */
160
+ export interface OrphanMigrationResult {
161
+ item: string;
162
+ status: 'ok' | 'warn' | 'fail';
163
+ detail?: string;
164
+ }
165
+ /**
166
+ * Parse the `migrate-orphans` helper summary (one record per line):
167
+ * OK <service> re-homed
168
+ * WARN <service> <detail> pinned copy written but orphan not removed
169
+ * FAIL <service> <detail> could not re-home (orphan left intact)
170
+ * Unknown lines are ignored. Exported for unit testing without a keychain.
171
+ */
172
+ export declare function parseOrphanMigrationOutput(stdout: string): OrphanMigrationResult[];
173
+ /**
174
+ * Re-home every pre-#279 orphaned data-protection item under `prefix` into the
175
+ * concrete access group, behind a SINGLE Touch ID prompt for the whole batch.
176
+ * The helper reads each orphan by its exact persistent ref, adds the pinned copy
177
+ * (add-before-delete: a failed add leaves the orphan intact), then deletes the
178
+ * orphan by ref. Returns one result per item. macOS only — no-op elsewhere.
179
+ *
180
+ * Throws on Touch ID cancellation (exit 4) so callers can distinguish "user
181
+ * aborted" from "nothing to do" (empty array).
182
+ */
183
+ export declare function migrateOrphanedKeychainItems(prefix: string): OrphanMigrationResult[];
143
184
  /** Options controlling how secret refs are resolved. */
144
185
  export interface ResolveOptions {
145
186
  /** Translate a short keychain ID to a fully namespaced item name. */