@phnx-labs/agents-cli 1.22.66 → 1.22.69

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 (161) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +21 -8
  3. package/dist/bootstrap.js +6 -3
  4. package/dist/commands/browser.js +46 -21
  5. package/dist/commands/daemon-test-harness.d.ts +1 -1
  6. package/dist/commands/daemon-test-harness.js +2 -2
  7. package/dist/commands/daemon.js +21 -22
  8. package/dist/commands/exec.js +50 -0
  9. package/dist/commands/feed.js +20 -7
  10. package/dist/commands/monitors.js +5 -2
  11. package/dist/commands/projects.d.ts +26 -6
  12. package/dist/commands/projects.js +55 -22
  13. package/dist/commands/repo.js +57 -19
  14. package/dist/commands/resume.d.ts +16 -0
  15. package/dist/commands/resume.js +41 -8
  16. package/dist/commands/routines.js +42 -21
  17. package/dist/commands/send.js +29 -2
  18. package/dist/commands/sessions-inject.d.ts +58 -0
  19. package/dist/commands/sessions-inject.js +143 -7
  20. package/dist/commands/sessions-optimize.js +1 -1
  21. package/dist/commands/sessions-picker.js +1 -0
  22. package/dist/commands/sessions.js +4 -11
  23. package/dist/commands/share.d.ts +5 -3
  24. package/dist/commands/share.js +73 -20
  25. package/dist/commands/ssh.js +205 -2
  26. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  27. package/dist/lib/accounting/account-pool-collect.js +6 -4
  28. package/dist/lib/accounting/usage-ingest.js +4 -2
  29. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  30. package/dist/lib/accounting/usage-sync.js +66 -210
  31. package/dist/lib/accounting/usage.d.ts +18 -5
  32. package/dist/lib/accounting/usage.js +165 -20
  33. package/dist/lib/auth-health.d.ts +8 -0
  34. package/dist/lib/auth-health.js +4 -4
  35. package/dist/lib/boot-profile.d.ts +14 -0
  36. package/dist/lib/boot-profile.js +66 -0
  37. package/dist/lib/browser/caller-identity.d.ts +12 -0
  38. package/dist/lib/browser/caller-identity.js +19 -0
  39. package/dist/lib/browser/ipc.d.ts +37 -32
  40. package/dist/lib/browser/ipc.js +146 -94
  41. package/dist/lib/browser/task-index.d.ts +10 -2
  42. package/dist/lib/browser/task-index.js +22 -3
  43. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  44. package/dist/lib/channels/providers/desktop.js +5 -4
  45. package/dist/lib/claude-account-token.js +108 -4
  46. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  47. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  48. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  49. package/dist/lib/daemon/auth-sync-service.js +17 -6
  50. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  51. package/dist/lib/daemon/catchup-service.js +51 -0
  52. package/dist/lib/daemon/daemon.d.ts +12 -22
  53. package/dist/lib/daemon/daemon.js +463 -176
  54. package/dist/lib/daemon/runner.js +2 -0
  55. package/dist/lib/daemon/service.d.ts +22 -4
  56. package/dist/lib/daemon/service.js +2 -2
  57. package/dist/lib/daemon/supervisor.d.ts +55 -15
  58. package/dist/lib/daemon/supervisor.js +119 -29
  59. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  60. package/dist/lib/daemon/usage-sync-service.js +22 -18
  61. package/dist/lib/daemon-health.js +36 -31
  62. package/dist/lib/daemon-services.d.ts +1 -1
  63. package/dist/lib/daemon-services.js +12 -2
  64. package/dist/lib/daemon-ticks.d.ts +9 -6
  65. package/dist/lib/daemon-ticks.js +14 -8
  66. package/dist/lib/devices/health.d.ts +38 -2
  67. package/dist/lib/devices/health.js +43 -5
  68. package/dist/lib/devices/registry.js +2 -0
  69. package/dist/lib/devices/worker-pick.d.ts +1 -1
  70. package/dist/lib/devices/worker-pick.js +4 -1
  71. package/dist/lib/exec.js +15 -0
  72. package/dist/lib/feed/watch.d.ts +3 -0
  73. package/dist/lib/feed/watch.js +13 -3
  74. package/dist/lib/feed-broadcast.d.ts +64 -5
  75. package/dist/lib/feed-broadcast.js +124 -22
  76. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  77. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  78. package/dist/lib/fleet-shared-state.d.ts +38 -0
  79. package/dist/lib/fleet-shared-state.js +105 -0
  80. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  81. package/dist/lib/hosts/remote-cmd.js +12 -3
  82. package/dist/lib/lock-compromise.d.ts +8 -0
  83. package/dist/lib/lock-compromise.js +12 -0
  84. package/dist/lib/monitors/engine.d.ts +2 -1
  85. package/dist/lib/monitors/engine.js +27 -2
  86. package/dist/lib/monitors/sources/command.js +13 -3
  87. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  88. package/dist/lib/monitors/sources/failure.js +52 -0
  89. package/dist/lib/monitors/sources/types.d.ts +9 -0
  90. package/dist/lib/owner-message.d.ts +12 -0
  91. package/dist/lib/owner-message.js +44 -0
  92. package/dist/lib/refresh-coordinator.js +2 -0
  93. package/dist/lib/run-trace-sync.d.ts +28 -0
  94. package/dist/lib/run-trace-sync.js +99 -0
  95. package/dist/lib/secrets/filestore.d.ts +4 -0
  96. package/dist/lib/secrets/filestore.js +164 -3
  97. package/dist/lib/secrets/push.d.ts +10 -0
  98. package/dist/lib/secrets/push.js +86 -7
  99. package/dist/lib/secrets/remote.d.ts +18 -6
  100. package/dist/lib/secrets/remote.js +29 -4
  101. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  102. package/dist/lib/secrets/reserved-sync.js +119 -101
  103. package/dist/lib/session/active.d.ts +13 -1
  104. package/dist/lib/session/active.js +5 -0
  105. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  106. package/dist/lib/session/actor-sidecar.js +2 -0
  107. package/dist/lib/session/db.d.ts +16 -2
  108. package/dist/lib/session/db.js +73 -22
  109. package/dist/lib/session/discover.d.ts +12 -3
  110. package/dist/lib/session/discover.js +187 -26
  111. package/dist/lib/session/linear.d.ts +13 -0
  112. package/dist/lib/session/linear.js +44 -0
  113. package/dist/lib/session/live-metadata.js +1 -0
  114. package/dist/lib/session/parse.js +2 -3
  115. package/dist/lib/session/prompt.d.ts +17 -0
  116. package/dist/lib/session/prompt.js +35 -0
  117. package/dist/lib/session/recovery.d.ts +43 -6
  118. package/dist/lib/session/recovery.js +80 -10
  119. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  120. package/dist/lib/session/remote/remote-list.js +29 -4
  121. package/dist/lib/session/remote/watch.d.ts +25 -2
  122. package/dist/lib/session/remote/watch.js +188 -11
  123. package/dist/lib/session/session-cache.d.ts +2 -1
  124. package/dist/lib/session/session-cache.js +1 -0
  125. package/dist/lib/session/state.js +11 -13
  126. package/dist/lib/session/types.d.ts +2 -0
  127. package/dist/lib/share/backend.d.ts +2 -2
  128. package/dist/lib/share/backend.js +20 -9
  129. package/dist/lib/share/delete.d.ts +5 -1
  130. package/dist/lib/share/delete.js +7 -2
  131. package/dist/lib/share/http-error.d.ts +52 -0
  132. package/dist/lib/share/http-error.js +65 -0
  133. package/dist/lib/share/publish.d.ts +67 -11
  134. package/dist/lib/share/publish.js +98 -16
  135. package/dist/lib/share/worker-template.js +105 -9
  136. package/dist/lib/smart-launch.js +27 -4
  137. package/dist/lib/ssh-exec.d.ts +2 -0
  138. package/dist/lib/ssh-exec.js +20 -4
  139. package/dist/lib/storage/index.d.ts +14 -0
  140. package/dist/lib/storage/index.js +14 -0
  141. package/dist/lib/storage/selection.d.ts +48 -0
  142. package/dist/lib/storage/selection.js +39 -0
  143. package/dist/lib/storage/visibility.d.ts +82 -0
  144. package/dist/lib/storage/visibility.js +99 -0
  145. package/dist/lib/teams/agents.js +3 -1
  146. package/dist/lib/teams/placement-probe.js +1 -0
  147. package/dist/lib/teams/registry.js +2 -0
  148. package/dist/lib/teams/scheduler.d.ts +8 -1
  149. package/dist/lib/teams/scheduler.js +4 -1
  150. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  151. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  152. package/dist/lib/traces/backend.js +13 -2
  153. package/dist/lib/traces/sync.d.ts +7 -0
  154. package/dist/lib/traces/sync.js +9 -0
  155. package/dist/lib/usage-refresh.d.ts +8 -2
  156. package/dist/lib/usage-refresh.js +3 -3
  157. package/dist/lib/worktree/held.d.ts +166 -0
  158. package/dist/lib/worktree/held.js +368 -0
  159. package/package.json +2 -2
  160. package/dist/lib/account-state-service.d.ts +0 -21
  161. package/dist/lib/account-state-service.js +0 -60
@@ -21,7 +21,7 @@
21
21
  * item and its keychain twin carry identical names:
22
22
  * `agents-cli.bundles.<name>` and `agents-cli.secrets.<bundle>.<key>`.
23
23
  */
24
- import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
24
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync } from 'crypto';
25
25
  import * as fs from 'fs';
26
26
  import * as os from 'os';
27
27
  import * as path from 'path';
@@ -210,6 +210,128 @@ export function decryptForFallback(enc, passphrase) {
210
210
  const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
211
211
  return plaintext.toString('utf8');
212
212
  }
213
+ // ---------- bundle-metadata plaintext cache (PHNX-3585) ----------
214
+ //
215
+ // Enumerating file-backed bundles — `agents secrets list`, and the
216
+ // account-rotation `readAccountRegistry` on the `agents run` hot path — decrypts
217
+ // EVERY bundle's metadata item, and each decrypt runs a fresh scrypt KDF (~12ms)
218
+ // because every `.enc` file carries its own random salt. On a box with dozens of
219
+ // bundles that is hundreds of ms of pure KDF before the harness even starts (the
220
+ // ~0.55s New-Claude boot cost in PHNX-3585).
221
+ //
222
+ // Bundle METADATA is non-secret by contract (secrets/bundles.ts `writeBundle`:
223
+ // "Metadata is non-sensitive by contract", stored no-ACL so `secrets list`
224
+ // enumerates without Touch ID) — the vars hold `keychain:`/`env:`/literal REFS,
225
+ // never the secret bytes, which live in separate `agents-cli.secrets.<bundle>.*`
226
+ // value items. So its decrypted JSON is safe to cache in cleartext, at the SAME
227
+ // 0600 protection as the ciphertext beside it.
228
+ //
229
+ // The cache keys each entry on the `.enc` file's (mtimeMs,size) plus a
230
+ // passphrase fingerprint, so ANY bundle write (new mtime/size) or passphrase
231
+ // change (new fingerprint; rotation also re-writes every file) misses and
232
+ // re-derives — no staleness, self-healing on any miss. It is scoped to the
233
+ // metadata namespace ONLY (`agents-cli.bundles.`); secret VALUE items are never
234
+ // read through it, so no secret is ever written to the cache.
235
+ const META_ITEM_PREFIX = 'agents-cli.bundles.';
236
+ function isMetaItem(item) {
237
+ return item.startsWith(META_ITEM_PREFIX);
238
+ }
239
+ let metaCache = null;
240
+ let metaCacheDirty = false;
241
+ let metaCacheFlushRegistered = false;
242
+ function metaCachePath() {
243
+ // A SIBLING of the store dir, never inside it — the same rule the store lock
244
+ // above follows, so a rotation's store-dir rename / `.rotate-*` sweep never
245
+ // moves or reaps it. Under `~/.agents/.cache/`, so it is machine-local and
246
+ // never enters `agents repo push/pull`.
247
+ return `${fileDir()}.meta-cache.json`;
248
+ }
249
+ function passphraseFingerprint() {
250
+ // Non-reversible tag, never the passphrase itself — so a passphrase change
251
+ // invalidates the cache without the key ever landing on disk in the clear.
252
+ return createHash('sha256').update(getPassphrase()).digest('hex').slice(0, 16);
253
+ }
254
+ function loadMetaCache() {
255
+ if (metaCache)
256
+ return metaCache;
257
+ const keyFp = passphraseFingerprint();
258
+ try {
259
+ const doc = JSON.parse(fs.readFileSync(metaCachePath(), 'utf8'));
260
+ if (doc && doc.v === 1 && doc.keyFp === keyFp && doc.entries && typeof doc.entries === 'object') {
261
+ metaCache = doc;
262
+ return metaCache;
263
+ }
264
+ }
265
+ catch {
266
+ /* absent or corrupt — rebuild from scratch */
267
+ }
268
+ metaCache = { v: 1, keyFp, entries: {} };
269
+ return metaCache;
270
+ }
271
+ /** Decrypted metadata for `item` if the cache holds a fresh entry, else null. */
272
+ function metaCacheGet(item, fp) {
273
+ let st;
274
+ try {
275
+ st = fs.statSync(fp);
276
+ }
277
+ catch {
278
+ return null;
279
+ }
280
+ const e = loadMetaCache().entries[item];
281
+ return e && e.mtimeMs === st.mtimeMs && e.size === st.size ? e.plain : null;
282
+ }
283
+ /** Record freshly-decrypted metadata for `item`, flushed once at process exit.
284
+ * `snapshot` is the pre-readFileSync stat — the identity of the bytes just
285
+ * decrypted. Skip the put if a post-decrypt stat does not match: a concurrent
286
+ * writer during scrypt must not pin that plaintext to the new file identity. */
287
+ function metaCachePut(item, fp, plain, snapshot) {
288
+ try {
289
+ const now = fs.statSync(fp);
290
+ if (now.mtimeMs !== snapshot.mtimeMs || now.size !== snapshot.size)
291
+ return;
292
+ }
293
+ catch {
294
+ return;
295
+ }
296
+ loadMetaCache().entries[item] = { mtimeMs: snapshot.mtimeMs, size: snapshot.size, plain };
297
+ metaCacheDirty = true;
298
+ if (!metaCacheFlushRegistered) {
299
+ metaCacheFlushRegistered = true;
300
+ process.on('exit', flushMetaCache);
301
+ }
302
+ }
303
+ /** Drop a cached entry the moment its file is rewritten or removed, so a
304
+ * same-process write→read never returns pre-write metadata (mtime/size alone
305
+ * can collide within a millisecond on an unchanged-length rewrite). */
306
+ function metaCacheEvict(item) {
307
+ if (!isMetaItem(item))
308
+ return;
309
+ const cache = metaCache;
310
+ if (cache && cache.entries[item]) {
311
+ delete cache.entries[item];
312
+ metaCacheDirty = true;
313
+ }
314
+ }
315
+ function flushMetaCache() {
316
+ if (!metaCacheDirty || !metaCache)
317
+ return;
318
+ metaCacheDirty = false;
319
+ // Prune entries whose `.enc` file is gone so churn can't grow the cache without
320
+ // bound; a surviving stale entry would re-validate against mtime/size anyway.
321
+ for (const item of Object.keys(metaCache.entries)) {
322
+ if (!fs.existsSync(fileFor(item)))
323
+ delete metaCache.entries[item];
324
+ }
325
+ try {
326
+ ensureFileDir();
327
+ const tmp = `${metaCachePath()}.${process.pid}.tmp`;
328
+ fs.writeFileSync(tmp, JSON.stringify(metaCache), { mode: 0o600 });
329
+ fs.renameSync(tmp, metaCachePath());
330
+ }
331
+ catch {
332
+ /* the cache is advisory — a write failure just re-derives next run */
333
+ }
334
+ }
213
335
  // ---------- file backend ----------
214
336
  function fileFor(item) {
215
337
  return path.join(fileDir(), `${item}.enc`);
@@ -226,6 +348,26 @@ function fileGet(item) {
226
348
  if (!fs.existsSync(fp)) {
227
349
  throw new Error(`Secret '${item}' not found in encrypted store.`);
228
350
  }
351
+ // Non-secret metadata short-circuits the scrypt decrypt when the file is
352
+ // unchanged (see the cache block above). Secret VALUE items skip the cache
353
+ // entirely, so they always decrypt fresh and never touch cleartext storage.
354
+ const metaItem = isMetaItem(item);
355
+ if (metaItem) {
356
+ const hit = metaCacheGet(item, fp);
357
+ if (hit !== null)
358
+ return hit;
359
+ }
360
+ // Identity of the bytes we are about to decrypt. A post-decrypt stat would
361
+ // attribute this plaintext to a concurrent writer's new file.
362
+ let snapshot;
363
+ if (metaItem) {
364
+ try {
365
+ snapshot = fs.statSync(fp);
366
+ }
367
+ catch {
368
+ snapshot = undefined;
369
+ }
370
+ }
229
371
  const raw = fs.readFileSync(fp, 'utf8');
230
372
  let parsed;
231
373
  try {
@@ -234,12 +376,16 @@ function fileGet(item) {
234
376
  catch {
235
377
  throw new Error(`Encrypted secret file ${fp} is corrupt (not valid JSON).`);
236
378
  }
379
+ let plain;
237
380
  try {
238
- return decryptForFallback(parsed, getPassphrase());
381
+ plain = decryptForFallback(parsed, getPassphrase());
239
382
  }
240
383
  catch {
241
384
  throw new Error(`Failed to decrypt '${item}'. Wrong AGENTS_SECRETS_PASSPHRASE or tampered file.`);
242
385
  }
386
+ if (metaItem && snapshot)
387
+ metaCachePut(item, fp, plain, snapshot);
388
+ return plain;
243
389
  }
244
390
  function fileGetBatch(items) {
245
391
  const out = new Map();
@@ -262,12 +408,13 @@ function fileSet(item, value) {
262
408
  const enc = encryptForFallback(value, getPassphrase());
263
409
  fs.writeFileSync(fileFor(item), JSON.stringify(enc), { mode: 0o600 });
264
410
  });
411
+ metaCacheEvict(item);
265
412
  }
266
413
  function fileDelete(item) {
267
414
  const fp = fileFor(item);
268
415
  if (!fs.existsSync(fp))
269
416
  return true; // idempotent, matches secret-tool clear
270
- return withStoreLock(() => {
417
+ const deleted = withStoreLock(() => {
271
418
  // Re-check under the lock — a rotation may have swapped the dir since the
272
419
  // pre-lock existence probe above.
273
420
  if (!fs.existsSync(fp))
@@ -275,6 +422,8 @@ function fileDelete(item) {
275
422
  fs.unlinkSync(fp);
276
423
  return true;
277
424
  });
425
+ metaCacheEvict(item);
426
+ return deleted;
278
427
  }
279
428
  function fileList(prefix) {
280
429
  const dir = fileDir();
@@ -921,6 +1070,18 @@ export function _resetFileStoreForTest(opts = {}) {
921
1070
  warnedAutoPassphrase = false;
922
1071
  lockAcquireTimeoutMsOverride = null;
923
1072
  lockStaleMsOverride = null;
1073
+ // Drop the in-memory metadata cache so a test that repoints the store dir or
1074
+ // passphrase never reads another fixture's cached plaintext (the on-disk cache
1075
+ // is per-dir and keyed by the passphrase fingerprint, so it self-invalidates
1076
+ // too, but the process-level map must be cleared explicitly).
1077
+ metaCache = null;
1078
+ metaCacheDirty = false;
1079
+ }
1080
+ /** Test-only: flush the in-memory metadata cache to disk now. Production flushes
1081
+ * once at process exit; a test needs the on-disk copy mid-run to simulate the
1082
+ * next `agents` process reading it. */
1083
+ export function _flushMetaCacheForTest() {
1084
+ flushMetaCache();
924
1085
  }
925
1086
  /** Test-only: shorten the store-lock acquire timeout so a contended-lock assertion
926
1087
  * fails fast instead of waiting out the 30s production budget. */
@@ -34,6 +34,8 @@ export interface PushBundleOptions {
34
34
  agentOnly?: boolean;
35
35
  /** Non-secret literals whose bundle value kind must survive the dotenv transport. */
36
36
  literalValues?: Record<string, string>;
37
+ /** Per-SSH-operation deadline. Async daemon callers must set this explicitly. */
38
+ timeoutMs?: number;
37
39
  }
38
40
  export interface PushBundleResult {
39
41
  ok: boolean;
@@ -121,3 +123,11 @@ export declare function planPushTransport(resolved: ResolvedBundleForPush, bundl
121
123
  export declare function pushResolvedBundleToHost(resolved: ResolvedBundleForPush, bundle: string, host: string, opts: PushBundleOptions): PushBundleResult;
122
124
  /** Resolve and push in one call — for a single host. */
123
125
  export declare function pushBundleToHost(bundle: string, host: string, opts: PushBundleOptions): PushBundleResult;
126
+ /**
127
+ * Async, kill-bounded push for daemon services. It preserves the foreground
128
+ * primitive's transport, read-back verification, and literal restoration; only
129
+ * the SSH execution strategy differs.
130
+ */
131
+ export declare function pushResolvedBundleToHostAsync(resolved: ResolvedBundleForPush, bundle: string, host: string, opts: PushBundleOptions): Promise<PushBundleResult>;
132
+ /** Resolve locally once, then run the daemon-safe async push. */
133
+ export declare function pushBundleToHostAsync(bundle: string, host: string, opts: PushBundleOptions): Promise<PushBundleResult>;
@@ -19,10 +19,10 @@
19
19
  * reading a bundle can prompt (Touch ID); doing it per host would prompt per
20
20
  * host. `export --device a,b,c` resolves once and pushes three times.
21
21
  */
22
- import { sshExec } from '../ssh-exec.js';
22
+ import { sshExec, sshExecAsync } from '../ssh-exec.js';
23
23
  import { remoteShellFor, buildWindowsStdinImportCommand } from '../hosts/remote-cmd.js';
24
24
  import { resolveRemoteOsSync } from '../hosts/remote-os.js';
25
- import { remoteSecretsRaw, verifyRemoteKeychainPush, keychainWriteFailureMessage, buildRemoteFileImportCommand, credentialTransportSshOpts, } from './remote.js';
25
+ import { remoteSecretsRaw, remoteSecretsRawAsync, verifyRemoteKeychainPush, verifyRemoteKeychainPushAsync, keychainWriteFailureMessage, buildRemoteFileImportCommand, credentialTransportSshOpts, } from './remote.js';
26
26
  import { readAndResolveBundleEnv } from './bundles.js';
27
27
  /**
28
28
  * Serialize a resolved env map to `.env` lines that round-trip losslessly through
@@ -138,8 +138,8 @@ export function pushResolvedBundleToHost(resolved, bundle, host, opts) {
138
138
  // the raw-`ssh` branch's half of that; the `remote-secrets` branch and the
139
139
  // read-back/policy/literal follow-ups pass `secret: true` for the same posture.
140
140
  const res = plan.kind === 'ssh'
141
- ? sshExec(host, plan.remoteCmd, { input: plan.input, hostKeyOpts: credentialTransportSshOpts(host).hostKeyOpts, multiplex: plan.multiplex })
142
- : remoteSecretsRaw(host, plan.args, { input: plan.input, osLookupName: host, secret: true });
141
+ ? sshExec(host, plan.remoteCmd, { input: plan.input, timeoutMs: opts.timeoutMs, hostKeyOpts: credentialTransportSshOpts(host).hostKeyOpts, multiplex: plan.multiplex })
142
+ : remoteSecretsRaw(host, plan.args, { input: plan.input, timeoutMs: opts.timeoutMs, osLookupName: host, secret: true });
143
143
  if (res.code === null) {
144
144
  return fail(res.stderr.trim() || (res.timedOut ? 'ssh timed out' : 'ssh failed'));
145
145
  }
@@ -154,7 +154,7 @@ export function pushResolvedBundleToHost(resolved, bundle, host, opts) {
154
154
  // - file: a forwarded AGENTS_SECRETS_PASSPHRASE keys ciphertext to a secret
155
155
  // the destination daemon does not hold (PHNX-2371).
156
156
  // Read the bundle back the way a later resolve will and FAIL LOUDLY.
157
- const verdict = verifyRemoteKeychainPush(host, bundle, Object.keys(resolved.env), { osLookupName: host, secret: true });
157
+ const verdict = verifyRemoteKeychainPush(host, bundle, Object.keys(resolved.env), { osLookupName: host, secret: true, timeoutMs: opts.timeoutMs });
158
158
  if (!verdict.ok) {
159
159
  if (opts.remoteBackend === 'keychain' && verdict.kind === 'locked-keychain') {
160
160
  return fail(keychainWriteFailureMessage(host, bundle, verdict.reason));
@@ -168,12 +168,12 @@ export function pushResolvedBundleToHost(resolved, bundle, host, opts) {
168
168
  return fail(`pushed '${bundle}' but could not verify it on the remote: ${verdict.reason}`);
169
169
  }
170
170
  for (const step of planLiteralRestoration(bundle, opts.literalValues)) {
171
- const removed = remoteSecretsRaw(host, step.removeArgs, { osLookupName: host, secret: true });
171
+ const removed = remoteSecretsRaw(host, step.removeArgs, { osLookupName: host, secret: true, timeoutMs: opts.timeoutMs });
172
172
  if (removed.code !== 0) {
173
173
  const msg = (removed.stderr || removed.stdout || '').trim();
174
174
  return fail(`pushed '${bundle}' but could not replace transported ${step.key}${msg ? `: ${msg}` : ''}`);
175
175
  }
176
- const literal = remoteSecretsRaw(host, step.addArgs, { osLookupName: host, secret: true });
176
+ const literal = remoteSecretsRaw(host, step.addArgs, { osLookupName: host, secret: true, timeoutMs: opts.timeoutMs });
177
177
  if (literal.code !== 0) {
178
178
  const msg = (literal.stderr || literal.stdout || '').trim();
179
179
  return fail(`pushed '${bundle}' but could not preserve literal ${step.key}${msg ? `: ${msg}` : ''}`);
@@ -192,3 +192,82 @@ export function pushResolvedBundleToHost(resolved, bundle, host, opts) {
192
192
  export function pushBundleToHost(bundle, host, opts) {
193
193
  return pushResolvedBundleToHost(resolveBundleForPush(bundle, opts.operation, { agentOnly: opts.agentOnly }), bundle, host, opts);
194
194
  }
195
+ /**
196
+ * Async, kill-bounded push for daemon services. It preserves the foreground
197
+ * primitive's transport, read-back verification, and literal restoration; only
198
+ * the SSH execution strategy differs.
199
+ */
200
+ export async function pushResolvedBundleToHostAsync(resolved, bundle, host, opts) {
201
+ const fail = (message) => ({ ok: false, host, bundle, keyCount: resolved.keyCount, message });
202
+ const plan = planPushTransport(resolved, bundle, host, opts);
203
+ if (plan.kind === 'refuse')
204
+ return fail(plan.message);
205
+ const res = plan.kind === 'ssh'
206
+ ? await sshExecAsync(host, plan.remoteCmd, {
207
+ input: plan.input,
208
+ timeoutMs: opts.timeoutMs,
209
+ hostKeyOpts: credentialTransportSshOpts(host).hostKeyOpts,
210
+ multiplex: plan.multiplex,
211
+ })
212
+ : await remoteSecretsRawAsync(host, plan.args, {
213
+ input: plan.input,
214
+ timeoutMs: opts.timeoutMs,
215
+ osLookupName: host,
216
+ secret: true,
217
+ });
218
+ if (res.code === null)
219
+ return fail(res.stderr.trim() || (res.timedOut ? 'ssh timed out' : 'ssh failed'));
220
+ if (res.code !== 0) {
221
+ const msg = (res.stderr || res.stdout || '').trim();
222
+ return fail(`remote import failed (exit ${res.code})${msg ? `: ${msg}` : ''}`);
223
+ }
224
+ const verdict = await verifyRemoteKeychainPushAsync(host, bundle, Object.keys(resolved.env), {
225
+ osLookupName: host,
226
+ secret: true,
227
+ timeoutMs: opts.timeoutMs,
228
+ });
229
+ if (!verdict.ok) {
230
+ if (opts.remoteBackend === 'keychain' && verdict.kind === 'locked-keychain') {
231
+ return fail(keychainWriteFailureMessage(host, bundle, verdict.reason));
232
+ }
233
+ if (opts.remoteBackend === 'file') {
234
+ return fail(`${host}: pushed '${bundle}' but the remote could not decrypt it (${verdict.reason}). ` +
235
+ `File-backend export never forwards AGENTS_SECRETS_PASSPHRASE — the remote keys ` +
236
+ `the bundle under its own machine-local key. If the destination still cannot read, ` +
237
+ `unset a stale AGENTS_SECRETS_PASSPHRASE there (\`agents doctor\`).`);
238
+ }
239
+ return fail(`pushed '${bundle}' but could not verify it on the remote: ${verdict.reason}`);
240
+ }
241
+ for (const step of planLiteralRestoration(bundle, opts.literalValues)) {
242
+ const removed = await remoteSecretsRawAsync(host, step.removeArgs, {
243
+ osLookupName: host,
244
+ secret: true,
245
+ timeoutMs: opts.timeoutMs,
246
+ });
247
+ if (removed.code !== 0) {
248
+ const msg = (removed.stderr || removed.stdout || '').trim();
249
+ return fail(`pushed '${bundle}' but could not replace transported ${step.key}${msg ? `: ${msg}` : ''}`);
250
+ }
251
+ const literal = await remoteSecretsRawAsync(host, step.addArgs, {
252
+ osLookupName: host,
253
+ secret: true,
254
+ timeoutMs: opts.timeoutMs,
255
+ });
256
+ if (literal.code !== 0) {
257
+ const msg = (literal.stderr || literal.stdout || '').trim();
258
+ return fail(`pushed '${bundle}' but could not preserve literal ${step.key}${msg ? `: ${msg}` : ''}`);
259
+ }
260
+ }
261
+ const remoteMsg = (res.stdout || '').trim().split('\n').map((line) => line.trim()).filter(Boolean).pop();
262
+ return {
263
+ ok: true,
264
+ host,
265
+ bundle,
266
+ keyCount: resolved.keyCount,
267
+ message: remoteMsg || `${resolved.keyCount} key(s) exported`,
268
+ };
269
+ }
270
+ /** Resolve locally once, then run the daemon-safe async push. */
271
+ export function pushBundleToHostAsync(bundle, host, opts) {
272
+ return pushResolvedBundleToHostAsync(resolveBundleForPush(bundle, opts.operation, { agentOnly: opts.agentOnly }), bundle, host, opts);
273
+ }
@@ -6,6 +6,14 @@
6
6
  * and injects it ephemerally without persisting it locally.
7
7
  */
8
8
  import { type SshExecResult } from '../ssh-exec.js';
9
+ export declare const REMOTE_TIMEOUT_MS = 30000;
10
+ export interface RemoteSecretsRawOptions {
11
+ tty?: boolean;
12
+ input?: string;
13
+ osLookupName?: string;
14
+ secret?: boolean;
15
+ timeoutMs?: number;
16
+ }
9
17
  /**
10
18
  * SSH options for secret-carrying transport. Pins the managed host key and
11
19
  * disables multiplex so no reusable control socket lingers for other invocations.
@@ -53,12 +61,9 @@ export declare function splitBundleRef(ref: string): {
53
61
  * result. Used by browse commands; `tty` forces `-tt` so remote passphrase
54
62
  * prompts can surface.
55
63
  */
56
- export declare function remoteSecretsRaw(target: string, args: string[], opts?: {
57
- tty?: boolean;
58
- input?: string;
59
- osLookupName?: string;
60
- secret?: boolean;
61
- }): SshExecResult;
64
+ export declare function remoteSecretsRaw(target: string, args: string[], opts?: RemoteSecretsRawOptions): SshExecResult;
65
+ /** Async, kill-bounded inverse of {@link remoteSecretsRaw} for daemon callers. */
66
+ export declare function remoteSecretsRawAsync(target: string, args: string[], opts?: RemoteSecretsRawOptions): Promise<SshExecResult>;
62
67
  /**
63
68
  * Run a remote `agents secrets <args>` foreground with local stdio inherited,
64
69
  * so the remote sees a real TTY and its passphrase prompt surfaces and reads
@@ -122,7 +127,14 @@ export declare function keychainWriteFailureMessage(host: string, bundle: string
122
127
  export declare function verifyRemoteKeychainPush(target: string, bundle: string, pushedKeys: string[], opts?: {
123
128
  osLookupName?: string;
124
129
  secret?: boolean;
130
+ timeoutMs?: number;
125
131
  }): RemoteKeychainWriteVerification;
132
+ /** Async, kill-bounded read-back verification for daemon-originated pushes. */
133
+ export declare function verifyRemoteKeychainPushAsync(target: string, bundle: string, pushedKeys: string[], opts?: {
134
+ osLookupName?: string;
135
+ secret?: boolean;
136
+ timeoutMs?: number;
137
+ }): Promise<RemoteKeychainWriteVerification>;
126
138
  /**
127
139
  * `bash -lc` command + stdin payload for a file-backed remote import.
128
140
  *
@@ -5,7 +5,7 @@
5
5
  * Browse streams remote stdout verbatim; use resolves a bundle to an env map
6
6
  * and injects it ephemerally without persisting it locally.
7
7
  */
8
- import { sshExec, sshStream, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
8
+ import { sshExec, sshExecAsync, sshStream, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
9
9
  import { resolveHost } from '../hosts/registry.js';
10
10
  import { emitSecretAudit } from './audit.js';
11
11
  import { sshTargetFor } from '../hosts/types.js';
@@ -13,7 +13,7 @@ import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
13
13
  import { resolveRemoteOsSync } from '../hosts/remote-os.js';
14
14
  import { isLoaderOrInterpreterEnv } from './bundles.js';
15
15
  import { isHostPinned, hostKeyCheckingOpts } from '../devices/known-hosts.js';
16
- const REMOTE_TIMEOUT_MS = 30_000;
16
+ export const REMOTE_TIMEOUT_MS = 30_000;
17
17
  /** The host part of an ssh target (`user@host` -> `host`) for known_hosts matching. */
18
18
  function hostKeyLookupName(target) {
19
19
  return target.split('@').pop() ?? target;
@@ -125,7 +125,20 @@ export function remoteSecretsRaw(target, args, opts = {}) {
125
125
  ? { ...posture, extraSshArgs: ['-tt'], multiplex: false }
126
126
  : posture;
127
127
  return sshExec(target, remoteCmd, {
128
- timeoutMs: REMOTE_TIMEOUT_MS,
128
+ timeoutMs: opts.timeoutMs ?? REMOTE_TIMEOUT_MS,
129
+ input: opts.input,
130
+ ...conn,
131
+ });
132
+ }
133
+ /** Async, kill-bounded inverse of {@link remoteSecretsRaw} for daemon callers. */
134
+ export function remoteSecretsRawAsync(target, args, opts = {}) {
135
+ const remoteCmd = buildRemoteAgentsInvocation(['secrets', ...args], undefined, osForTarget(target, opts.osLookupName));
136
+ const posture = opts.secret ? credentialTransportSshOpts(target) : {};
137
+ const conn = opts.tty
138
+ ? { ...posture, extraSshArgs: ['-tt'], multiplex: false }
139
+ : posture;
140
+ return sshExecAsync(target, remoteCmd, {
141
+ timeoutMs: opts.timeoutMs ?? REMOTE_TIMEOUT_MS,
129
142
  input: opts.input,
130
143
  ...conn,
131
144
  });
@@ -270,9 +283,21 @@ export function keychainWriteFailureMessage(host, bundle, reason) {
270
283
  export function verifyRemoteKeychainPush(target, bundle, pushedKeys, opts = {}) {
271
284
  const remoteCmd = buildRemoteAgentsInvocation(['secrets', 'export', bundle, '--plaintext', '--format', 'json'], undefined, osForTarget(target, opts.osLookupName), { AGENTS_SECRETS_REMOTE_TRANSPORT: '1' });
272
285
  const res = sshExec(target, remoteCmd, {
273
- timeoutMs: REMOTE_TIMEOUT_MS,
286
+ timeoutMs: opts.timeoutMs ?? REMOTE_TIMEOUT_MS,
287
+ ...(opts.secret ? credentialTransportSshOpts(target) : {}),
288
+ });
289
+ return evaluateRemoteKeychainPushResult(pushedKeys, res);
290
+ }
291
+ /** Async, kill-bounded read-back verification for daemon-originated pushes. */
292
+ export async function verifyRemoteKeychainPushAsync(target, bundle, pushedKeys, opts = {}) {
293
+ const remoteCmd = buildRemoteAgentsInvocation(['secrets', 'export', bundle, '--plaintext', '--format', 'json'], undefined, osForTarget(target, opts.osLookupName), { AGENTS_SECRETS_REMOTE_TRANSPORT: '1' });
294
+ const res = await sshExecAsync(target, remoteCmd, {
295
+ timeoutMs: opts.timeoutMs ?? REMOTE_TIMEOUT_MS,
274
296
  ...(opts.secret ? credentialTransportSshOpts(target) : {}),
275
297
  });
298
+ return evaluateRemoteKeychainPushResult(pushedKeys, res);
299
+ }
300
+ function evaluateRemoteKeychainPushResult(pushedKeys, res) {
276
301
  if (res.code !== 0) {
277
302
  const why = res.timedOut ? 'timed out' : res.code === null ? 'ssh failed' : `exit ${res.code}`;
278
303
  const stderr = `${why}${(res.stderr || res.stdout || '').trim() ? `: ${(res.stderr || res.stdout).trim()}` : ''}`;
@@ -1,11 +1,13 @@
1
1
  import { type PushBundleResult } from './push.js';
2
2
  import { type DeviceProfile } from '../devices/registry.js';
3
- import type { DeviceProbe } from '../fleet/types.js';
3
+ import { type SharedAuthStatus } from '../fleet-shared-state.js';
4
+ /** Each import/read-back SSH operation gets this deadline plus the SSH hard-kill grace. */
5
+ export declare const AUTH_SYNC_PUSH_DEADLINE_MS = 20000;
4
6
  export interface AuthSyncDevice {
5
7
  name: string;
6
8
  reachable: boolean;
7
9
  pinned: boolean;
8
- remoteHasAuth: boolean;
10
+ remoteAuth: SharedAuthStatus | 'unknown';
9
11
  }
10
12
  export type AuthSyncPlanItem = {
11
13
  action: 'push';
@@ -15,8 +17,10 @@ export type AuthSyncPlanItem = {
15
17
  device: string;
16
18
  reason: string;
17
19
  };
18
- export declare function planAuthBundlePush(localAuthOk: boolean, devices: AuthSyncDevice[]): AuthSyncPlanItem[];
20
+ export declare function planAuthBundlePush(localAuthOk: boolean, localIsPublisher: boolean, devices: AuthSyncDevice[]): AuthSyncPlanItem[];
19
21
  export interface AuthSyncResult {
22
+ publisher: string | null;
23
+ stateChanged: boolean;
20
24
  pushed: string[];
21
25
  skipped: Array<{
22
26
  device: string;
@@ -27,10 +31,6 @@ export interface AuthSyncResult {
27
31
  message: string;
28
32
  }>;
29
33
  }
30
- export interface AuthSyncProbe {
31
- reachable: boolean;
32
- remoteHasAuth: boolean;
33
- }
34
34
  export interface AuthSyncDeps {
35
35
  inspectLocal?: () => {
36
36
  exists: boolean;
@@ -38,28 +38,29 @@ export interface AuthSyncDeps {
38
38
  };
39
39
  listDevices?: () => DeviceProfile[];
40
40
  localName?: string;
41
+ userAgentsDir?: string;
41
42
  isPinned?: (name: string) => boolean;
42
- /**
43
- * Per-device reachability + auth presence. Default: `probeDevice({ withSecrets: true })`
44
- * — the same remote-presence probe `fleet apply` uses in `decideSecretPush`.
45
- */
46
- probe?: (device: DeviceProfile) => AuthSyncProbe;
47
- push?: (bundle: string, host: string) => PushBundleResult;
43
+ push?: (bundle: string, host: string) => Promise<PushBundleResult>;
48
44
  sshTarget?: (device: DeviceProfile) => string;
49
45
  }
46
+ export interface PublishAuthVerdictOptions {
47
+ inspectLocal?: () => {
48
+ exists: boolean;
49
+ ok: boolean;
50
+ };
51
+ localName?: string;
52
+ userAgentsDir?: string;
53
+ }
54
+ export interface PublishAuthVerdictResult {
55
+ device: string;
56
+ status: SharedAuthStatus;
57
+ changed: boolean;
58
+ error: string | null;
59
+ }
60
+ /** Publish only safe readiness metadata; useful immediately before a repo push. */
61
+ export declare function publishReservedAuthVerdict(options?: PublishAuthVerdictOptions): PublishAuthVerdictResult;
50
62
  /**
51
- * Map a `fleet apply` device probe onto the auth-sync skip/push inputs.
52
- *
53
- * Presence is an own-property check on `remoteBundles` (same gate as
54
- * `decideSecretPush`): a missing listing, a parse failure, or a prototype
55
- * name must all mean "not present, so push" — never "present, so skip".
56
- */
57
- export declare function authPresenceFromProbe(probe: DeviceProbe): AuthSyncProbe;
58
- /** Production default: live SSH probe of reachability + `secrets list --json`. */
59
- export declare function defaultAuthSyncProbe(device: DeviceProfile): AuthSyncProbe;
60
- /**
61
- * Push the local file-backed `auth` bundle to every pinned registered device
62
- * that does not already have it. No-op when the local bundle is missing or on
63
- * the wrong backend (doctor flags that separately).
63
+ * Publish local readiness, elect one ready source, then asynchronously provision
64
+ * only peers whose shared verdict says the bundle is missing.
64
65
  */
65
- export declare function syncReservedAuthBundle(deps?: AuthSyncDeps): AuthSyncResult;
66
+ export declare function syncReservedAuthBundle(deps?: AuthSyncDeps): Promise<AuthSyncResult>;