@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
@@ -264,8 +264,14 @@ export function getKeychainTokens(items) {
264
264
  }
265
265
  return result;
266
266
  }
267
- /** Store or update a secret value in the keychain/keyring. Device-local; biometry-gated on macOS. */
268
- export function setKeychainToken(item, value) {
267
+ /** Store or update a secret value in the keychain/keyring. Device-local;
268
+ * biometry-gated on macOS. `opts.noAcl` (the `never` prompt-policy) writes our
269
+ * item WITHOUT the biometry access control so later reads are fully silent — it
270
+ * routes through the signed helper's `set-no-acl` path. A pinned helper that
271
+ * predates that path rejects the unknown command (exit 2) and this throws,
272
+ * rather than silently falling back to an ACL'd `set` (which would behave like
273
+ * `always`). Ignored by the Linux/Windows/test backends, which have no ACL. */
274
+ export function setKeychainToken(item, value, opts) {
269
275
  if (backend) {
270
276
  backend.set(item, value);
271
277
  return;
@@ -303,12 +309,22 @@ export function setKeychainToken(item, value) {
303
309
  return;
304
310
  }
305
311
  const bin = getKeychainHelperPath();
306
- const result = spawnSync(bin, ['set', item, os.userInfo().username], {
312
+ // `never` policy no-ACL write. The `set-no-acl` subcommand exists only in a
313
+ // re-notarized helper; an older pinned helper dies with "Unknown command:
314
+ // set-no-acl" (exit 2), surfaced below — never a silent ACL'd downgrade.
315
+ const helperCmd = opts?.noAcl ? 'set-no-acl' : 'set';
316
+ const result = spawnSync(bin, [helperCmd, item, os.userInfo().username], {
307
317
  input: value,
308
318
  stdio: ['pipe', 'pipe', 'pipe'],
309
319
  });
310
320
  if (result.status !== 0) {
311
321
  const msg = result.stderr?.toString().trim();
322
+ if (opts?.noAcl && /unknown command/i.test(msg ?? '')) {
323
+ throw new Error(`The 'never' prompt-policy needs a Keychain helper with the no-ACL write path, ` +
324
+ `but the installed helper does not support it. Rebuild + re-notarize the signed ` +
325
+ `helper (scripts/build-keychain-helper.sh) and re-pin its sha, then retry. ` +
326
+ `(helper said: ${msg})`);
327
+ }
312
328
  throw new Error(msg || `Failed to write keychain item '${item}'.`);
313
329
  }
314
330
  }
@@ -419,6 +435,89 @@ export function migrateKeychainItem(item) {
419
435
  const msg = result.stderr?.toString().trim();
420
436
  throw new Error(msg || `Failed to migrate keychain item '${item}'.`);
421
437
  }
438
+ /**
439
+ * Enumerate data-protection items whose service starts with `prefix` that live
440
+ * under a NON-concrete access group — pre-#279 "orphans" filed under the implicit
441
+ * default group (the literal `2HTP252L87.*`) that the pinned-group queries can't
442
+ * see. Attributes only: never decrypts, never prompts. macOS only — Linux/Windows
443
+ * and the test backend have no access-group concept, so this returns [].
444
+ */
445
+ export function listOrphanedKeychainItems(prefix) {
446
+ if (backend)
447
+ return [];
448
+ assertSupportedPlatform();
449
+ if (isLinux() || isWindows())
450
+ return [];
451
+ const bin = getKeychainHelperPath();
452
+ const result = spawnSync(bin, ['list-orphans', prefix, os.userInfo().username], {
453
+ stdio: ['ignore', 'pipe', 'pipe'],
454
+ });
455
+ if (result.status !== 0) {
456
+ const msg = result.stderr?.toString().trim();
457
+ throw new Error(msg || `Failed to enumerate orphaned keychain items with prefix '${prefix}'.`);
458
+ }
459
+ const out = result.stdout?.toString() || '';
460
+ return out.split('\n').map((s) => s.trim()).filter(Boolean);
461
+ }
462
+ /**
463
+ * Parse the `migrate-orphans` helper summary (one record per line):
464
+ * OK <service> re-homed
465
+ * WARN <service> <detail> pinned copy written but orphan not removed
466
+ * FAIL <service> <detail> could not re-home (orphan left intact)
467
+ * Unknown lines are ignored. Exported for unit testing without a keychain.
468
+ */
469
+ export function parseOrphanMigrationOutput(stdout) {
470
+ const results = [];
471
+ for (const line of stdout.split('\n')) {
472
+ const trimmed = line.trim();
473
+ if (!trimmed)
474
+ continue;
475
+ const sep = trimmed.indexOf(' ');
476
+ const tag = sep === -1 ? trimmed : trimmed.slice(0, sep);
477
+ const rest = sep === -1 ? '' : trimmed.slice(sep + 1);
478
+ if (tag === 'OK') {
479
+ // OK carries only the service name (no trailing detail).
480
+ results.push({ item: rest, status: 'ok' });
481
+ }
482
+ else if (tag === 'WARN' || tag === 'FAIL') {
483
+ // WARN/FAIL are 'TAG <service> <detail>'. Service names are space-free
484
+ // (validateBundleName / validateEnvKey), so the first token IS the exact
485
+ // service — this stays consistent with listOrphanedKeychainItems for the
486
+ // healed-set reconciliation in migrate-acl.
487
+ const item = rest.split(' ')[0] ?? rest;
488
+ results.push({ item, status: tag === 'WARN' ? 'warn' : 'fail', detail: rest });
489
+ }
490
+ }
491
+ return results;
492
+ }
493
+ /**
494
+ * Re-home every pre-#279 orphaned data-protection item under `prefix` into the
495
+ * concrete access group, behind a SINGLE Touch ID prompt for the whole batch.
496
+ * The helper reads each orphan by its exact persistent ref, adds the pinned copy
497
+ * (add-before-delete: a failed add leaves the orphan intact), then deletes the
498
+ * orphan by ref. Returns one result per item. macOS only — no-op elsewhere.
499
+ *
500
+ * Throws on Touch ID cancellation (exit 4) so callers can distinguish "user
501
+ * aborted" from "nothing to do" (empty array).
502
+ */
503
+ export function migrateOrphanedKeychainItems(prefix) {
504
+ if (backend)
505
+ return [];
506
+ assertSupportedPlatform();
507
+ if (isLinux() || isWindows())
508
+ return [];
509
+ const bin = getKeychainHelperPath();
510
+ const result = spawnSync(bin, ['migrate-orphans', prefix, os.userInfo().username], {
511
+ stdio: ['ignore', 'pipe', 'pipe'],
512
+ });
513
+ if (result.status === 4)
514
+ throw new Error('Touch ID cancelled during orphan migration.');
515
+ if (result.status !== 0) {
516
+ const msg = result.stderr?.toString().trim();
517
+ throw new Error(msg || `Failed to migrate orphaned keychain items with prefix '${prefix}'.`);
518
+ }
519
+ return parseOrphanMigrationOutput(result.stdout?.toString() || '');
520
+ }
422
521
  function expandHome(p) {
423
522
  if (p.startsWith('~/') || p === '~') {
424
523
  return path.join(os.homedir(), p.slice(1));
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Zero-knowledge / JIT secret delivery over MCP (issue #333).
3
+ *
4
+ * A stdio MCP server exposing a single `get_secret(bundle, key)` tool. Unlike
5
+ * `agents secrets exec`, which spawns the child process with EVERY resolved
6
+ * value baked into `process.env` (any subprocess can `printenv` the lot — see
7
+ * buildSecretsExecEnv in src/commands/secrets.ts), this server hands an
8
+ * MCP-speaking agent framework ONE credential at a time, by name, at call
9
+ * time. The raw value is returned only inside the tool result: it never enters
10
+ * the child's environment, is never logged, and never appears in `tools/list`
11
+ * metadata (names only).
12
+ *
13
+ * Resolution reuses the canonical bundle read path
14
+ * (readAndResolveBundleEnv -> getKeychainToken); no keychain access is
15
+ * re-implemented here. On Linux the libsecret backend (or its encrypted-file
16
+ * fallback for locked/headless collections) applies transparently through that
17
+ * abstraction, so the server stays backend-agnostic and works unchanged on
18
+ * macOS.
19
+ *
20
+ * Transport: the MCP stdio transport is newline-delimited JSON-RPC 2.0 (one
21
+ * message per line, UTF-8, no embedded newlines). That framing is trivial, so
22
+ * we implement it directly rather than pull in the @modelcontextprotocol/sdk
23
+ * server dependency — keeping the tree dependency-free and the handler pure and
24
+ * unit-testable in-process.
25
+ */
26
+ /** MCP protocol revision this server negotiates. */
27
+ export declare const MCP_PROTOCOL_VERSION = "2024-11-05";
28
+ /** The single tool this server exposes. */
29
+ export declare const GET_SECRET_TOOL = "get_secret";
30
+ /** serverInfo.name reported at `initialize`. */
31
+ export declare const MCP_SERVER_NAME = "agents-secrets";
32
+ export interface JsonRpcRequest {
33
+ jsonrpc?: string;
34
+ id?: string | number | null;
35
+ method?: string;
36
+ params?: unknown;
37
+ }
38
+ interface JsonRpcSuccess {
39
+ jsonrpc: '2.0';
40
+ id: string | number | null;
41
+ result: unknown;
42
+ }
43
+ interface JsonRpcErrorResponse {
44
+ jsonrpc: '2.0';
45
+ id: string | number | null;
46
+ error: {
47
+ code: number;
48
+ message: string;
49
+ };
50
+ }
51
+ export type JsonRpcResponse = JsonRpcSuccess | JsonRpcErrorResponse;
52
+ /** Per-bundle metadata for `tools/list` — names only, NEVER values. */
53
+ export interface BundleKeyMetadata {
54
+ bundle: string;
55
+ description?: string;
56
+ keys: string[];
57
+ }
58
+ /**
59
+ * Enumerate every bundle and its key names WITHOUT resolving or reading any
60
+ * value. Mirrors how `agents secrets list` surfaces bundle metadata: names are
61
+ * safe to expose, values are not. `listBundles()` reads only the bundle
62
+ * metadata items (var definitions), never the secret items behind them.
63
+ */
64
+ export declare function listSecretMetadata(): BundleKeyMetadata[];
65
+ /**
66
+ * Resolve a single secret value by bundle + key through the canonical read
67
+ * path. Throws a clear error when the bundle or the key is absent.
68
+ *
69
+ * The bundle metadata is read first (readBundle) so a missing key is reported
70
+ * without triggering resolution — and, on macOS, without prompting Touch ID —
71
+ * for the other keys in the bundle.
72
+ */
73
+ export declare function resolveSecret(bundle: string, key: string): string;
74
+ /**
75
+ * Handle one parsed JSON-RPC request. Returns the response object, or `null`
76
+ * for notifications (which take no reply). Pure — no I/O, no logging — so tests
77
+ * drive it directly.
78
+ */
79
+ export declare function handleMcpRequest(req: JsonRpcRequest, ctx?: {
80
+ version?: string;
81
+ }): JsonRpcResponse | null;
82
+ /**
83
+ * Run the stdio MCP server: read newline-delimited JSON-RPC requests from
84
+ * `input`, write responses to `output`. Resolves when the input stream ends.
85
+ * Streams are injectable so tests can drive a full request/response loop
86
+ * without spawning a process.
87
+ */
88
+ export declare function runSecretsMcpServer(opts?: {
89
+ input?: NodeJS.ReadableStream;
90
+ output?: NodeJS.WritableStream;
91
+ version?: string;
92
+ }): Promise<void>;
93
+ export {};
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Zero-knowledge / JIT secret delivery over MCP (issue #333).
3
+ *
4
+ * A stdio MCP server exposing a single `get_secret(bundle, key)` tool. Unlike
5
+ * `agents secrets exec`, which spawns the child process with EVERY resolved
6
+ * value baked into `process.env` (any subprocess can `printenv` the lot — see
7
+ * buildSecretsExecEnv in src/commands/secrets.ts), this server hands an
8
+ * MCP-speaking agent framework ONE credential at a time, by name, at call
9
+ * time. The raw value is returned only inside the tool result: it never enters
10
+ * the child's environment, is never logged, and never appears in `tools/list`
11
+ * metadata (names only).
12
+ *
13
+ * Resolution reuses the canonical bundle read path
14
+ * (readAndResolveBundleEnv -> getKeychainToken); no keychain access is
15
+ * re-implemented here. On Linux the libsecret backend (or its encrypted-file
16
+ * fallback for locked/headless collections) applies transparently through that
17
+ * abstraction, so the server stays backend-agnostic and works unchanged on
18
+ * macOS.
19
+ *
20
+ * Transport: the MCP stdio transport is newline-delimited JSON-RPC 2.0 (one
21
+ * message per line, UTF-8, no embedded newlines). That framing is trivial, so
22
+ * we implement it directly rather than pull in the @modelcontextprotocol/sdk
23
+ * server dependency — keeping the tree dependency-free and the handler pure and
24
+ * unit-testable in-process.
25
+ */
26
+ import * as readline from 'readline';
27
+ import { listBundles, readAndResolveBundleEnv, readBundle, validateBundleName, } from './bundles.js';
28
+ /** MCP protocol revision this server negotiates. */
29
+ export const MCP_PROTOCOL_VERSION = '2024-11-05';
30
+ /** The single tool this server exposes. */
31
+ export const GET_SECRET_TOOL = 'get_secret';
32
+ /** serverInfo.name reported at `initialize`. */
33
+ export const MCP_SERVER_NAME = 'agents-secrets';
34
+ // JSON-RPC 2.0 error codes we use.
35
+ const JSONRPC_PARSE_ERROR = -32700;
36
+ const JSONRPC_INVALID_REQUEST = -32600;
37
+ const JSONRPC_METHOD_NOT_FOUND = -32601;
38
+ const JSONRPC_INVALID_PARAMS = -32602;
39
+ /**
40
+ * Enumerate every bundle and its key names WITHOUT resolving or reading any
41
+ * value. Mirrors how `agents secrets list` surfaces bundle metadata: names are
42
+ * safe to expose, values are not. `listBundles()` reads only the bundle
43
+ * metadata items (var definitions), never the secret items behind them.
44
+ */
45
+ export function listSecretMetadata() {
46
+ return listBundles()
47
+ .map((b) => ({
48
+ bundle: b.name,
49
+ description: b.description,
50
+ keys: Object.keys(b.vars).sort(),
51
+ }))
52
+ .sort((a, b) => a.bundle.localeCompare(b.bundle));
53
+ }
54
+ /**
55
+ * Resolve a single secret value by bundle + key through the canonical read
56
+ * path. Throws a clear error when the bundle or the key is absent.
57
+ *
58
+ * The bundle metadata is read first (readBundle) so a missing key is reported
59
+ * without triggering resolution — and, on macOS, without prompting Touch ID —
60
+ * for the other keys in the bundle.
61
+ */
62
+ export function resolveSecret(bundle, key) {
63
+ validateBundleName(bundle);
64
+ // readBundle throws `Secrets bundle '<name>' not found.` for a missing bundle.
65
+ const meta = readBundle(bundle);
66
+ if (!Object.prototype.hasOwnProperty.call(meta.vars, key)) {
67
+ const available = Object.keys(meta.vars).sort();
68
+ throw new Error(`Key '${key}' not found in bundle '${bundle}'.` +
69
+ (available.length ? ` Available keys: ${available.join(', ')}.` : ' Bundle has no keys.'));
70
+ }
71
+ const { env } = readAndResolveBundleEnv(bundle, { caller: 'secrets-mcp' });
72
+ const value = env[key];
73
+ if (value === undefined) {
74
+ throw new Error(`Key '${key}' in bundle '${bundle}' could not be resolved.`);
75
+ }
76
+ return value;
77
+ }
78
+ /** Build the `get_secret` tool definition, advertising available names only. */
79
+ function getSecretToolDefinition() {
80
+ let catalog = '(no bundles configured)';
81
+ try {
82
+ const meta = listSecretMetadata();
83
+ if (meta.length > 0) {
84
+ catalog = meta
85
+ .map((b) => `${b.bundle}: ${b.keys.length ? b.keys.join(', ') : '(no keys)'}`)
86
+ .join(' | ');
87
+ }
88
+ }
89
+ catch {
90
+ // Listing is best-effort decoration; never fail tools/list because the
91
+ // keychain enumeration hiccuped.
92
+ catalog = '(unavailable)';
93
+ }
94
+ return {
95
+ name: GET_SECRET_TOOL,
96
+ description: 'Fetch a single secret value by bundle + key, resolved at call time. The value ' +
97
+ 'is returned only in this tool result and never enters the process environment. ' +
98
+ `Available bundles and keys (names only): ${catalog}`,
99
+ inputSchema: {
100
+ type: 'object',
101
+ properties: {
102
+ bundle: { type: 'string', description: 'Secrets bundle name' },
103
+ key: { type: 'string', description: 'Key / env-var name within the bundle' },
104
+ },
105
+ required: ['bundle', 'key'],
106
+ additionalProperties: false,
107
+ },
108
+ };
109
+ }
110
+ function success(id, result) {
111
+ return { jsonrpc: '2.0', id, result };
112
+ }
113
+ function rpcError(id, code, message) {
114
+ return { jsonrpc: '2.0', id, error: { code, message } };
115
+ }
116
+ /**
117
+ * A tool-execution failure. Per the MCP spec, tool errors are reported IN the
118
+ * result (`isError: true`) — not as a JSON-RPC error — so the model sees and
119
+ * can react to the message. The value channel (`content`) still carries no
120
+ * secret here.
121
+ */
122
+ function toolError(id, message) {
123
+ return success(id, { content: [{ type: 'text', text: message }], isError: true });
124
+ }
125
+ function handleToolCall(id, params) {
126
+ const p = (params ?? {});
127
+ if (p.name !== GET_SECRET_TOOL) {
128
+ return rpcError(id, JSONRPC_INVALID_PARAMS, `Unknown tool: ${String(p.name)}`);
129
+ }
130
+ const args = (p.arguments ?? {});
131
+ if (typeof args.bundle !== 'string' || !args.bundle || typeof args.key !== 'string' || !args.key) {
132
+ return toolError(id, `${GET_SECRET_TOOL} requires non-empty string 'bundle' and 'key' arguments.`);
133
+ }
134
+ try {
135
+ const value = resolveSecret(args.bundle, args.key);
136
+ return success(id, { content: [{ type: 'text', text: value }] });
137
+ }
138
+ catch (err) {
139
+ return toolError(id, err.message);
140
+ }
141
+ }
142
+ /**
143
+ * Handle one parsed JSON-RPC request. Returns the response object, or `null`
144
+ * for notifications (which take no reply). Pure — no I/O, no logging — so tests
145
+ * drive it directly.
146
+ */
147
+ export function handleMcpRequest(req, ctx = {}) {
148
+ const id = req.id ?? null;
149
+ const isNotification = req.id === undefined || req.id === null;
150
+ switch (req.method) {
151
+ case 'initialize':
152
+ return success(id, {
153
+ protocolVersion: MCP_PROTOCOL_VERSION,
154
+ capabilities: { tools: {} },
155
+ serverInfo: { name: MCP_SERVER_NAME, version: ctx.version ?? '0.0.0' },
156
+ });
157
+ case 'notifications/initialized':
158
+ case 'initialized':
159
+ return null; // notification — no response
160
+ case 'ping':
161
+ return success(id, {});
162
+ case 'tools/list':
163
+ return success(id, { tools: [getSecretToolDefinition()] });
164
+ case 'tools/call':
165
+ return handleToolCall(id, req.params);
166
+ default:
167
+ if (isNotification)
168
+ return null; // unknown notification — stay silent
169
+ return rpcError(id, JSONRPC_METHOD_NOT_FOUND, `Method not found: ${String(req.method)}`);
170
+ }
171
+ }
172
+ function writeMessage(output, msg) {
173
+ output.write(JSON.stringify(msg) + '\n');
174
+ }
175
+ /**
176
+ * Run the stdio MCP server: read newline-delimited JSON-RPC requests from
177
+ * `input`, write responses to `output`. Resolves when the input stream ends.
178
+ * Streams are injectable so tests can drive a full request/response loop
179
+ * without spawning a process.
180
+ */
181
+ export async function runSecretsMcpServer(opts = {}) {
182
+ const input = opts.input ?? process.stdin;
183
+ const output = opts.output ?? process.stdout;
184
+ const rl = readline.createInterface({ input, crlfDelay: Infinity });
185
+ for await (const line of rl) {
186
+ const trimmed = line.trim();
187
+ if (!trimmed)
188
+ continue;
189
+ let req;
190
+ try {
191
+ req = JSON.parse(trimmed);
192
+ }
193
+ catch {
194
+ writeMessage(output, rpcError(null, JSONRPC_PARSE_ERROR, 'Parse error'));
195
+ continue;
196
+ }
197
+ if (!req || typeof req !== 'object' || typeof req.method !== 'string') {
198
+ writeMessage(output, rpcError((req && req.id) ?? null, JSONRPC_INVALID_REQUEST, 'Invalid Request'));
199
+ continue;
200
+ }
201
+ const res = handleMcpRequest(req, { version: opts.version });
202
+ if (res)
203
+ writeMessage(output, res);
204
+ }
205
+ }
@@ -15,10 +15,18 @@
15
15
  * file-backend passphrase travels over ssh stdin (first line) so it never lands
16
16
  * in argv / `ps` / remote shell history. Nothing is persisted locally.
17
17
  */
18
- import { sshExec, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
18
+ import { sshExec, assertValidSshTarget } from '../ssh-exec.js';
19
19
  import { resolveHost } from '../hosts/registry.js';
20
20
  import { sshTargetFor } from '../hosts/types.js';
21
+ import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
22
+ import { resolveRemoteOsSync } from '../hosts/remote-os.js';
21
23
  const REMOTE_TIMEOUT_MS = 30_000;
24
+ /** Remote OS for a target string (bare alias matches a device entry; a raw
25
+ * `user@host` falls back to POSIX). Threaded into the command builder so a
26
+ * Windows host gets PowerShell instead of `bash -lc`. */
27
+ function osForTarget(target) {
28
+ return resolveRemoteOsSync(target.split('@').pop() ?? target);
29
+ }
22
30
  /**
23
31
  * Resolve a `--host` value to an ssh target string. Tries the `agents hosts`
24
32
  * registry first (enrolled name → ssh-config alias / `user@host`); on a miss,
@@ -77,8 +85,7 @@ export function splitBundleRef(ref) {
77
85
  * remote Touch-ID / passphrase prompt can surface (e.g. `view --reveal`).
78
86
  */
79
87
  export function remoteSecretsRaw(target, args, opts = {}) {
80
- const inner = ['agents', 'secrets', ...args].map(shellQuote).join(' ');
81
- const remoteCmd = `bash -lc ${shellQuote(inner)}`;
88
+ const remoteCmd = buildRemoteAgentsInvocation(['secrets', ...args], undefined, osForTarget(target));
82
89
  return sshExec(target, remoteCmd, {
83
90
  timeoutMs: REMOTE_TIMEOUT_MS,
84
91
  input: opts.input,
@@ -100,8 +107,8 @@ export function remoteSecretsRaw(target, args, opts = {}) {
100
107
  */
101
108
  export async function remoteResolveEnv(target, bundle) {
102
109
  assertValidSshTarget(target);
103
- const exportCmd = `agents secrets export ${shellQuote(bundle)} --plaintext --format json`;
104
- const res = sshExec(target, `bash -lc ${shellQuote(exportCmd)}`, {
110
+ const remoteCmd = buildRemoteAgentsInvocation(['secrets', 'export', bundle, '--plaintext', '--format', 'json'], undefined, osForTarget(target));
111
+ const res = sshExec(target, remoteCmd, {
105
112
  timeoutMs: REMOTE_TIMEOUT_MS,
106
113
  });
107
114
  if (res.code !== 0) {
@@ -11,7 +11,7 @@
11
11
  * never leaves this module; the backend only ever sees ciphertext + KDF params.
12
12
  */
13
13
  import * as crypto from 'crypto';
14
- import { getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from './index.js';
14
+ import { deleteKeychainToken, getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from './index.js';
15
15
  import { readBundle, writeBundle, keychainItemsForBundle, validateBundleName, } from './bundles.js';
16
16
  import { rushSyncBackend } from './drivers/rush.js';
17
17
  // PBKDF2 cost. 600k SHA-256 iters matches OWASP 2023+ guidance and keeps a
@@ -93,14 +93,93 @@ function snapshotBundle(name) {
93
93
  }
94
94
  return { bundle, secrets };
95
95
  }
96
+ /**
97
+ * Materialize a decrypted snapshot locally, atomically.
98
+ *
99
+ * A naive "set each secret, then write metadata" is not crash-safe: if one
100
+ * `setKeychainToken` throws midway, the keychain is left with a half-applied
101
+ * set — some items carry the pulled values, others the old ones — and the
102
+ * bundle metadata may never be written, leaving orphaned/wrong items readable.
103
+ *
104
+ * So we snapshot the PRIOR value of every item we're about to touch, apply all
105
+ * writes, and on ANY failure roll back to exactly the pre-restore state
106
+ * (restore previously-existing items, delete items that didn't exist) before
107
+ * rethrowing. Metadata is committed only after every secret write succeeds; if
108
+ * that final write fails, the secret writes are rolled back too. Either the
109
+ * whole pull lands or the keychain is untouched.
110
+ */
96
111
  function restoreSnapshot(snap) {
97
112
  const bundle = snap.bundle;
98
113
  validateBundleName(bundle.name);
99
- for (const [shortId, value] of Object.entries(snap.secrets)) {
114
+ // Capture the pre-restore state of every item so a partial failure can be
115
+ // undone. hasKeychainToken never prompts; getKeychainToken reads the existing
116
+ // value via the same path the rest of the module uses.
117
+ const priors = [];
118
+ for (const shortId of Object.keys(snap.secrets)) {
100
119
  const item = secretsKeychainItem(bundle.name, shortId);
101
- setKeychainToken(item, value);
120
+ if (hasKeychainToken(item)) {
121
+ priors.push({ item, existed: true, value: getKeychainToken(item) });
122
+ }
123
+ else {
124
+ priors.push({ item, existed: false });
125
+ }
126
+ }
127
+ // Best-effort reversal to the captured pre-restore state. Each item is
128
+ // reverted independently so one failure doesn't abort the rest of the
129
+ // rollback. Reversal writes hit the SAME backend that just failed the restore
130
+ // (a locked/erroring keyring is the realistic trigger), so a reversal can
131
+ // itself throw — those items are collected and returned so the caller can
132
+ // report an INCOMPLETE rollback instead of falsely claiming a clean one.
133
+ const rollback = () => {
134
+ const stillDirty = [];
135
+ for (const prior of priors) {
136
+ try {
137
+ if (prior.existed)
138
+ setKeychainToken(prior.item, prior.value);
139
+ else
140
+ deleteKeychainToken(prior.item);
141
+ }
142
+ catch {
143
+ // Keep undoing the remaining items, but remember this one couldn't be
144
+ // reverted — it may now hold an orphaned or wrong value.
145
+ stillDirty.push(prior.item);
146
+ }
147
+ }
148
+ return stillDirty;
149
+ };
150
+ try {
151
+ for (const [shortId, value] of Object.entries(snap.secrets)) {
152
+ const item = secretsKeychainItem(bundle.name, shortId);
153
+ setKeychainToken(item, value);
154
+ }
155
+ }
156
+ catch (err) {
157
+ throw new Error(rollbackFailureMessage(bundle.name, 'writing secrets', err, rollback()));
158
+ }
159
+ // Commit metadata last. If it fails, undo the secret writes so nothing
160
+ // partial lingers.
161
+ try {
162
+ writeBundle(bundle);
163
+ }
164
+ catch (err) {
165
+ throw new Error(rollbackFailureMessage(bundle.name, 'writing metadata', err, rollback()));
166
+ }
167
+ }
168
+ /**
169
+ * Build the error thrown when a restore fails. When the rollback reverted every
170
+ * touched item (`dirty` empty) we truthfully say the keychain is back to the
171
+ * pre-restore state. When some reversal writes ALSO failed we must NOT claim a
172
+ * clean rollback — the message names the still-dirty items so the user knows the
173
+ * keychain is half-restored and which secrets to check.
174
+ */
175
+ function rollbackFailureMessage(name, phase, err, dirty) {
176
+ if (dirty.length === 0) {
177
+ return `Restore of bundle '${name}' failed while ${phase}; ` +
178
+ `rolled back to the pre-restore state. (${err.message})`;
102
179
  }
103
- writeBundle(bundle);
180
+ return `Restore of bundle '${name}' failed while ${phase}, and the rollback was INCOMPLETE — ` +
181
+ `these keychain items could not be reverted and may hold orphaned or wrong values: ${dirty.join(', ')}. ` +
182
+ `(original error: ${err.message})`;
104
183
  }
105
184
  /** Push a local bundle to the remote. Encrypts client-side; the backend only sees ciphertext. */
106
185
  export async function pushBundle(name, opts) {
@@ -53,6 +53,10 @@ export const CRED_MAX_CREDENTIAL_BLOB_SIZE = 2560;
53
53
  */
54
54
  const PS_SCRIPT = `
55
55
  $ErrorActionPreference = 'Stop'
56
+ # Output must not depend on the console codepage: a windowsHide child owns a
57
+ # fresh hidden console whose codepage is the OEM default (cp437), not the
58
+ # terminal's UTF-8 — the Node side always decodes stdout/stderr as UTF-8.
59
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
56
60
  Add-Type -TypeDefinition @'
57
61
  using System;
58
62
  using System.Runtime.InteropServices;
@@ -178,9 +182,12 @@ try {
178
182
  exit 0
179
183
  }
180
184
  'set' {
181
- $value = [Console]::In.ReadToEnd()
182
- $bytes = [System.Text.Encoding]::UTF8.GetBytes($value)
183
- [AgentsCred]::Set($target, $bytes)
185
+ # Raw bytes, not [Console]::In text — text decoding runs through the
186
+ # console codepage and corrupts non-ASCII UTF-8 from the Node side.
187
+ $stdin = [Console]::OpenStandardInput()
188
+ $ms = New-Object System.IO.MemoryStream
189
+ $stdin.CopyTo($ms)
190
+ [AgentsCred]::Set($target, $ms.ToArray())
184
191
  exit 0
185
192
  }
186
193
  'delete' {
@@ -219,6 +226,9 @@ function runCred(op, opts) {
219
226
  input: opts.input,
220
227
  stdio: ['pipe', 'pipe', 'pipe'],
221
228
  maxBuffer: 16 * 1024 * 1024,
229
+ // Never flash a console window when the caller has no console of its own
230
+ // (the scheduler daemon resolves bundles through here every sync cycle).
231
+ windowsHide: true,
222
232
  });
223
233
  return {
224
234
  status: result.status,
@@ -231,6 +241,7 @@ function runCred(op, opts) {
231
241
  function powershellAvailable() {
232
242
  const result = spawnSync(POWERSHELL, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], {
233
243
  stdio: ['ignore', 'ignore', 'ignore'],
244
+ windowsHide: true,
234
245
  });
235
246
  return !result.error && result.status === 0;
236
247
  }