@phnx-labs/agents-cli 1.22.25 → 1.22.27

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 (178) hide show
  1. package/CHANGELOG.md +427 -0
  2. package/README.md +35 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/bench.d.ts +2 -0
  7. package/dist/commands/bench.js +101 -0
  8. package/dist/commands/browser.js +35 -0
  9. package/dist/commands/doctor.js +32 -10
  10. package/dist/commands/exec.js +17 -16
  11. package/dist/commands/insights.d.ts +25 -19
  12. package/dist/commands/insights.js +146 -38
  13. package/dist/commands/output.js +100 -25
  14. package/dist/commands/reconnect.d.ts +46 -0
  15. package/dist/commands/reconnect.js +109 -0
  16. package/dist/commands/resume.d.ts +11 -0
  17. package/dist/commands/resume.js +51 -0
  18. package/dist/commands/routines.js +2 -2
  19. package/dist/commands/secrets.d.ts +2 -8
  20. package/dist/commands/secrets.js +29 -105
  21. package/dist/commands/sessions-picker.js +12 -7
  22. package/dist/commands/sessions.d.ts +47 -1
  23. package/dist/commands/sessions.js +349 -37
  24. package/dist/commands/setup-secrets.d.ts +1 -0
  25. package/dist/commands/setup-secrets.js +1 -1
  26. package/dist/commands/setup.d.ts +26 -3
  27. package/dist/commands/setup.js +105 -46
  28. package/dist/commands/ssh.js +184 -11
  29. package/dist/commands/teams.d.ts +6 -0
  30. package/dist/commands/teams.js +46 -3
  31. package/dist/commands/trends.d.ts +8 -0
  32. package/dist/commands/trends.js +10 -156
  33. package/dist/index.js +17 -21
  34. package/dist/lib/agents.d.ts +11 -0
  35. package/dist/lib/agents.js +29 -2
  36. package/dist/lib/analytics/dashboard.d.ts +10 -6
  37. package/dist/lib/analytics/dashboard.js +6 -4
  38. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  39. package/dist/lib/analytics/mix-commands.js +229 -0
  40. package/dist/lib/analytics/recipes.d.ts +19 -14
  41. package/dist/lib/analytics/recipes.js +4 -2
  42. package/dist/lib/auth-health.d.ts +47 -3
  43. package/dist/lib/auth-health.js +74 -15
  44. package/dist/lib/bench/index.d.ts +4 -0
  45. package/dist/lib/bench/index.js +4 -0
  46. package/dist/lib/bench/runner.d.ts +16 -0
  47. package/dist/lib/bench/runner.js +111 -0
  48. package/dist/lib/bench/schema.d.ts +5 -0
  49. package/dist/lib/bench/schema.js +91 -0
  50. package/dist/lib/bench/storage.d.ts +5 -0
  51. package/dist/lib/bench/storage.js +32 -0
  52. package/dist/lib/bench/types.d.ts +40 -0
  53. package/dist/lib/bench/types.js +1 -0
  54. package/dist/lib/browser/ipc.d.ts +26 -0
  55. package/dist/lib/browser/ipc.js +139 -24
  56. package/dist/lib/browser/profiles.d.ts +11 -0
  57. package/dist/lib/browser/profiles.js +1 -1
  58. package/dist/lib/browser/stream.d.ts +14 -0
  59. package/dist/lib/browser/stream.js +71 -0
  60. package/dist/lib/channels/owner-sink.d.ts +27 -0
  61. package/dist/lib/channels/owner-sink.js +93 -0
  62. package/dist/lib/claude-account-token.d.ts +2 -0
  63. package/dist/lib/claude-account-token.js +27 -1
  64. package/dist/lib/crabbox/cli.d.ts +2 -0
  65. package/dist/lib/crabbox/cli.js +2 -0
  66. package/dist/lib/crabbox/lease.js +7 -1
  67. package/dist/lib/daemon.js +20 -0
  68. package/dist/lib/devices/connect.d.ts +2 -0
  69. package/dist/lib/devices/connect.js +7 -0
  70. package/dist/lib/devices/doctor-findings.d.ts +10 -1
  71. package/dist/lib/devices/doctor-findings.js +47 -1
  72. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  73. package/dist/lib/devices/harness-inventory.js +0 -0
  74. package/dist/lib/devices/registry.d.ts +2 -0
  75. package/dist/lib/devices/ssh-config.js +3 -0
  76. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  77. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  78. package/dist/lib/exec.bench.d.ts +1 -0
  79. package/dist/lib/exec.bench.js +186 -0
  80. package/dist/lib/exec.js +18 -8
  81. package/dist/lib/fleet/apply.d.ts +59 -3
  82. package/dist/lib/fleet/apply.js +191 -12
  83. package/dist/lib/fleet/remote-login.d.ts +4 -3
  84. package/dist/lib/fleet/remote-login.js +11 -9
  85. package/dist/lib/fleet/types.d.ts +21 -2
  86. package/dist/lib/gemini-settings.d.ts +0 -1
  87. package/dist/lib/gemini-settings.js +12 -7
  88. package/dist/lib/hooks/cache.js +15 -0
  89. package/dist/lib/hooks.d.ts +8 -5
  90. package/dist/lib/hooks.js +10 -6
  91. package/dist/lib/hosts/dispatch.js +7 -3
  92. package/dist/lib/hosts/passthrough.d.ts +45 -0
  93. package/dist/lib/hosts/passthrough.js +59 -8
  94. package/dist/lib/hosts/progress.d.ts +2 -1
  95. package/dist/lib/hosts/progress.js +4 -3
  96. package/dist/lib/hosts/providers/devices.js +1 -0
  97. package/dist/lib/hosts/ready.d.ts +6 -2
  98. package/dist/lib/hosts/ready.js +33 -8
  99. package/dist/lib/hosts/reconcile.d.ts +1 -1
  100. package/dist/lib/hosts/reconcile.js +16 -7
  101. package/dist/lib/hosts/reconnect.d.ts +56 -21
  102. package/dist/lib/hosts/reconnect.js +128 -71
  103. package/dist/lib/hosts/registry.d.ts +2 -2
  104. package/dist/lib/hosts/registry.js +3 -5
  105. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  106. package/dist/lib/hosts/remote-cmd.js +29 -0
  107. package/dist/lib/hosts/tasks.d.ts +2 -0
  108. package/dist/lib/hosts/types.d.ts +1 -0
  109. package/dist/lib/hosts/types.js +3 -0
  110. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  111. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  112. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  113. package/dist/lib/menubar/install-menubar.js +20 -9
  114. package/dist/lib/models.d.ts +30 -1
  115. package/dist/lib/models.js +42 -144
  116. package/dist/lib/pricing/cost.d.ts +9 -0
  117. package/dist/lib/pricing/cost.js +24 -0
  118. package/dist/lib/pricing/index.d.ts +1 -1
  119. package/dist/lib/pricing/index.js +1 -1
  120. package/dist/lib/redact.js +8 -3
  121. package/dist/lib/remote-agents-json.d.ts +32 -0
  122. package/dist/lib/remote-agents-json.js +47 -16
  123. package/dist/lib/resource-profiles.js +1 -2
  124. package/dist/lib/routine-notify-owner.d.ts +102 -0
  125. package/dist/lib/routine-notify-owner.js +232 -0
  126. package/dist/lib/routines.d.ts +10 -0
  127. package/dist/lib/routines.js +14 -2
  128. package/dist/lib/runner.d.ts +9 -4
  129. package/dist/lib/runner.js +182 -30
  130. package/dist/lib/sandbox.d.ts +0 -2
  131. package/dist/lib/sandbox.js +2 -19
  132. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  133. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  134. package/dist/lib/secrets/agent.js +3 -2
  135. package/dist/lib/secrets/lease.d.ts +25 -0
  136. package/dist/lib/secrets/lease.js +44 -0
  137. package/dist/lib/secrets/push.d.ts +94 -0
  138. package/dist/lib/secrets/push.js +145 -0
  139. package/dist/lib/secrets/reaper.d.ts +15 -1
  140. package/dist/lib/secrets/reaper.js +30 -3
  141. package/dist/lib/self-update.d.ts +20 -5
  142. package/dist/lib/self-update.js +93 -16
  143. package/dist/lib/session/db.d.ts +38 -3
  144. package/dist/lib/session/db.js +273 -16
  145. package/dist/lib/session/discover.d.ts +22 -1
  146. package/dist/lib/session/discover.js +239 -51
  147. package/dist/lib/session/insights.d.ts +37 -0
  148. package/dist/lib/session/insights.js +219 -9
  149. package/dist/lib/session/recovery.d.ts +17 -1
  150. package/dist/lib/session/recovery.js +111 -4
  151. package/dist/lib/session/remote-list.d.ts +10 -9
  152. package/dist/lib/session/remote-list.js +25 -23
  153. package/dist/lib/session/resume-owner.d.ts +55 -0
  154. package/dist/lib/session/resume-owner.js +69 -0
  155. package/dist/lib/session/team-filter.d.ts +65 -0
  156. package/dist/lib/session/team-filter.js +98 -3
  157. package/dist/lib/session/tool-index.js +133 -22
  158. package/dist/lib/session/tool-store.d.ts +26 -2
  159. package/dist/lib/session/tool-store.js +36 -17
  160. package/dist/lib/session/types.d.ts +33 -0
  161. package/dist/lib/smart-launch.d.ts +31 -5
  162. package/dist/lib/smart-launch.js +43 -7
  163. package/dist/lib/ssh-exec.d.ts +27 -0
  164. package/dist/lib/ssh-exec.js +42 -3
  165. package/dist/lib/ssh-tunnel.d.ts +3 -2
  166. package/dist/lib/ssh-tunnel.js +25 -16
  167. package/dist/lib/startup/command-registry.d.ts +2 -0
  168. package/dist/lib/startup/command-registry.js +6 -0
  169. package/dist/lib/teams/agents.d.ts +13 -0
  170. package/dist/lib/teams/agents.js +75 -7
  171. package/dist/lib/teams/placement-probe.d.ts +21 -0
  172. package/dist/lib/teams/placement-probe.js +135 -0
  173. package/dist/lib/teams/scheduler.d.ts +74 -1
  174. package/dist/lib/teams/scheduler.js +187 -10
  175. package/dist/lib/tmux/session.d.ts +8 -0
  176. package/dist/lib/tmux/session.js +22 -0
  177. package/dist/lib/types.d.ts +2 -0
  178. package/package.json +1 -1
@@ -8,6 +8,7 @@
8
8
  * (`readyProbe`, `bootstrapAgentsCli`, `buildRemoteAgentsInvocation`, `sshExec`).
9
9
  */
10
10
  import type { DeviceProfile } from '../devices/registry.js';
11
+ import { type RemoteBackend } from '../secrets/push.js';
11
12
  import type { DeviceDesired, DeviceProbe, DeviceDiff, FleetAction, FleetPlan, AuthFilePayload } from './types.js';
12
13
  /** Strip a version suffix from an agent spec: `claude@latest` -> `claude`. */
13
14
  export declare function agentIdOf(spec: string): string;
@@ -54,21 +55,76 @@ export interface DiffContext {
54
55
  /** agents-cli version the source is on — the fleet target version. */
55
56
  targetCliVersion: string;
56
57
  sourceAuth: SourceAuth;
57
- /** Secrets-bundle names the profile declares. Values are keychain-local and
58
- * can't be pushed, so each reachable device surfaces them as a manual recreate
59
- * (`needs-secret`) — informational, never an executed mutation. */
58
+ /** Secrets-bundle names the profile declares. */
60
59
  secretsBundles?: string[];
60
+ /** `--provision-secrets`. OFF by default: pushing a bundle moves credential
61
+ * VALUES to another machine, so it is opted into per invocation and never
62
+ * defaulted from the shared `agents.yaml` (RUSH-1968). */
63
+ provisionSecrets?: boolean;
64
+ /** Is this device's host key pinned? Injected so `decideSecretPush` stays pure
65
+ * and its refusals are testable against real known_hosts fixtures with no
66
+ * network. Absent = treated as unpinned, i.e. refuse. */
67
+ isHostPinned?: (device: string) => boolean;
68
+ /** `--force`: push a declared bundle even when the device already has it. */
69
+ forceSecrets?: boolean;
61
70
  }
62
71
  /** Pure: desired vs probed -> per-device diff + flat action list. */
63
72
  export declare function diffFleet(desired: DeviceDesired[], probes: Map<string, DeviceProbe>, ctx: DiffContext): FleetPlan;
73
+ /** Why a declared bundle is or is not pushed to one device. */
74
+ export interface SecretPushDecision {
75
+ push: boolean;
76
+ /** Where it would land on the remote. Only meaningful when `push`. */
77
+ backend: RemoteBackend;
78
+ /** Set when `push` is false — rendered as the `needs-secret` reminder. */
79
+ reason: string;
80
+ }
81
+ /**
82
+ * Decide whether `fleet apply` may push one declared bundle to one device.
83
+ *
84
+ * PURE — no ssh, no keychain, no filesystem beyond the injectable pin check — so
85
+ * every branch is unit-testable with no live fleet. Three gates, in order, and
86
+ * each REFUSAL still yields a `needs-secret` reminder rather than silence:
87
+ *
88
+ * 1. `--provision-secrets` must be set. Off by default, and deliberately a
89
+ * flag rather than an `agents.yaml` field: the manifest is shared, so a
90
+ * file-level default means someone else's `apply -y` ships credential
91
+ * values without deciding to (RUSH-1968's shape of accident).
92
+ * 2. The device must be reachable — nothing to push to otherwise.
93
+ * 3. The host key must be PINNED. This moves credential values to another
94
+ * machine, so it reuses the same bar `agents exec --copy-creds` already
95
+ * sets (EXEC-34): an unpinned device earns its pin through a normal
96
+ * `agents ssh <device>` first.
97
+ *
98
+ * Backend follows the platform, and this is the load-bearing default of the
99
+ * whole feature: **file on Linux, keychain on macOS/Windows**. A headless Linux
100
+ * box has no keychain (`lib/secrets/linux.ts`), and the file store there
101
+ * auto-provisions its OWN machine-local key — so each box ends up with an
102
+ * unshared at-rest key and NO passphrase is forwarded. That is the direct
103
+ * alternative to the fleet-wide shared secret this ticket exists to remove.
104
+ */
105
+ export declare function decideSecretPush(bundle: string, desired: DeviceDesired, probe: DeviceProbe, ctx: DiffContext): SecretPushDecision;
64
106
  export interface ProbeOptions {
65
107
  /** Also fetch per-agent installed versions (one extra `agents view --json`
66
108
  * round-trip). Enable only when the plan has a version-pinned spec. */
67
109
  withVersions?: boolean;
110
+ /** Also fetch which secrets bundles the device already has (one extra
111
+ * `agents secrets list --json`). Enable only when the manifest declares
112
+ * bundles and provisioning is on — same cost discipline as `withVersions`. */
113
+ withSecrets?: boolean;
68
114
  }
69
115
  /** Probe one device: reachability + agents-cli version + installed agent ids
70
116
  * (and, when `withVersions`, the installed version strings per agent). */
71
117
  export declare function probeDevice(device: DeviceProfile, opts?: ProbeOptions): DeviceProbe;
118
+ /**
119
+ * Narrow a remote `secrets list --json` payload to `name -> updated_at`.
120
+ *
121
+ * Exported and pure so the parse is unit-tested against real payload shapes with
122
+ * no live fleet. Returns `{}` rather than throwing on anything unexpected: the
123
+ * remote runs its own agents-cli version, and a parse failure must degrade to
124
+ * "unknown, so push" — never to "present, so skip", which would silently leave a
125
+ * device unprovisioned.
126
+ */
127
+ export declare function parseRemoteBundles(stdout: string): Record<string, string>;
72
128
  export interface ApplyStep {
73
129
  kind: FleetAction['kind'];
74
130
  ok: boolean;
@@ -8,7 +8,8 @@
8
8
  * (`readyProbe`, `bootstrapAgentsCli`, `buildRemoteAgentsInvocation`, `sshExec`).
9
9
  */
10
10
  import * as os from 'os';
11
- import { sshTargetFor } from '../devices/connect.js';
11
+ import { pushBundleToHost } from '../secrets/push.js';
12
+ import { deviceIdentityArgs, sshTargetFor } from '../devices/connect.js';
12
13
  import { readyProbe, bootstrapAgentsCli } from '../hosts/ready.js';
13
14
  import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
14
15
  import { sshExec } from '../ssh-exec.js';
@@ -167,14 +168,33 @@ export function diffFleet(desired, probes, ctx) {
167
168
  }
168
169
  }
169
170
  }
170
- // secrets surfaced, never pushed (values are keychain-local). Declared
171
- // once at the manifest level, so every reachable device gets the same
172
- // manual-recreate reminder. Not an executable action (see idempotence
173
- // check in commands/apply.ts, which excludes needs-* kinds).
171
+ // secrets. Declared once at the manifest level, so every reachable device
172
+ // is considered. Historically this was ALWAYS a manual reminder "surfaced,
173
+ // never pushed" and that gap is a direct cause of RUSH-1968: an operator
174
+ // who needed secrets on a worker box had no supported path, so they
175
+ // hand-exported the file store's master key across the fleet instead.
176
+ //
177
+ // It is now pushable, but only deliberately. `--provision-secrets` is off by
178
+ // default and is a FLAG, not a manifest field: `agents.yaml` is shared, and
179
+ // a file-level default would mean someone else's `apply -y` silently ships
180
+ // credential values — the same shape of accident this ticket is about.
181
+ // Everything the gate refuses stays a `needs-secret` reminder, so nothing
182
+ // is ever silently skipped.
174
183
  if (ctx.secretsBundles && ctx.secretsBundles.length > 0) {
175
184
  for (const bundle of ctx.secretsBundles) {
176
- secretsNeeded.push(bundle);
177
- rowActions.push({ device: d.device, kind: 'needs-secret', detail: `recreate secrets bundle '${bundle}' (\`agents secrets create ${bundle}\`)` });
185
+ const decision = decideSecretPush(bundle, d, probe, ctx);
186
+ if (decision.push) {
187
+ rowActions.push({
188
+ device: d.device,
189
+ kind: 'push-secret',
190
+ bundle,
191
+ detail: `push secrets bundle '${bundle}' (${decision.backend} backend)`,
192
+ });
193
+ }
194
+ else {
195
+ secretsNeeded.push(bundle);
196
+ rowActions.push({ device: d.device, kind: 'needs-secret', bundle, detail: decision.reason });
197
+ }
178
198
  }
179
199
  }
180
200
  }
@@ -183,6 +203,66 @@ export function diffFleet(desired, probes, ctx) {
183
203
  }
184
204
  return { devices, actions };
185
205
  }
206
+ /**
207
+ * Decide whether `fleet apply` may push one declared bundle to one device.
208
+ *
209
+ * PURE — no ssh, no keychain, no filesystem beyond the injectable pin check — so
210
+ * every branch is unit-testable with no live fleet. Three gates, in order, and
211
+ * each REFUSAL still yields a `needs-secret` reminder rather than silence:
212
+ *
213
+ * 1. `--provision-secrets` must be set. Off by default, and deliberately a
214
+ * flag rather than an `agents.yaml` field: the manifest is shared, so a
215
+ * file-level default means someone else's `apply -y` ships credential
216
+ * values without deciding to (RUSH-1968's shape of accident).
217
+ * 2. The device must be reachable — nothing to push to otherwise.
218
+ * 3. The host key must be PINNED. This moves credential values to another
219
+ * machine, so it reuses the same bar `agents exec --copy-creds` already
220
+ * sets (EXEC-34): an unpinned device earns its pin through a normal
221
+ * `agents ssh <device>` first.
222
+ *
223
+ * Backend follows the platform, and this is the load-bearing default of the
224
+ * whole feature: **file on Linux, keychain on macOS/Windows**. A headless Linux
225
+ * box has no keychain (`lib/secrets/linux.ts`), and the file store there
226
+ * auto-provisions its OWN machine-local key — so each box ends up with an
227
+ * unshared at-rest key and NO passphrase is forwarded. That is the direct
228
+ * alternative to the fleet-wide shared secret this ticket exists to remove.
229
+ */
230
+ export function decideSecretPush(bundle, desired, probe, ctx) {
231
+ const device = desired.device;
232
+ const backend = probe.platform === 'linux' ? 'file' : 'keychain';
233
+ const manual = `recreate secrets bundle '${bundle}' (\`agents ssh ${device} -- secrets create ${bundle}\`)`;
234
+ if (!ctx.provisionSecrets) {
235
+ return { push: false, backend, reason: `${manual} — or re-run with --provision-secrets to push it` };
236
+ }
237
+ if (!probe.reachable) {
238
+ return { push: false, backend, reason: manual };
239
+ }
240
+ // Already there? Skip — otherwise every `apply` re-resolves the bundle, and a
241
+ // resolve can prompt for Touch ID, so a converged fleet would nag on every run.
242
+ //
243
+ // Known limitation, stated rather than hidden: this compares PRESENCE (and
244
+ // carries `updated_at` for a future content check). It is a timestamp
245
+ // heuristic, not a content hash — a bundle whose VALUES changed locally still
246
+ // reads as present. `--force` is the way to overwrite regardless.
247
+ // hasOwnProperty, NOT `in`: `in` walks the prototype chain, so a bundle named
248
+ // `toString` / `constructor` / `valueOf` would read as present on an EMPTY map
249
+ // and be silently skipped — leaving that device unprovisioned, the worse of the
250
+ // two errors this gate can make.
251
+ if (!ctx.forceSecrets && probe.remoteBundles
252
+ && Object.prototype.hasOwnProperty.call(probe.remoteBundles, bundle)) {
253
+ return { push: false, backend, reason: `secrets bundle '${bundle}' already present on ${device} — pass --force to overwrite` };
254
+ }
255
+ if (!ctx.isHostPinned?.(device)) {
256
+ // Same bar as `exec --copy-creds`: never ship credential values to a host
257
+ // whose key we have not pinned.
258
+ return {
259
+ push: false,
260
+ backend,
261
+ reason: `${manual} — host key not pinned; run \`agents ssh ${device}\` once to pin it, then re-apply`,
262
+ };
263
+ }
264
+ return { push: true, backend, reason: '' };
265
+ }
186
266
  // ---- execution (real SSH; verified end-to-end, not unit-mocked) ----
187
267
  function osHint(platform) {
188
268
  return platform === 'windows' ? 'windows' : undefined;
@@ -202,13 +282,14 @@ export function probeDevice(device, opts) {
202
282
  return { device: device.name, reachable: false, platform: device.platform, installedAgents: [], note: e.message };
203
283
  }
204
284
  const hint = osHint(device.platform);
205
- const ready = readyProbe(target, hint);
285
+ const extraSshArgs = deviceIdentityArgs(device);
286
+ const ready = readyProbe(target, hint, extraSshArgs);
206
287
  if (!ready.reachable) {
207
288
  return { device: device.name, reachable: false, platform: device.platform, installedAgents: [], note: 'unreachable' };
208
289
  }
209
290
  let installed = [];
210
291
  const remoteCmd = buildRemoteAgentsInvocation(['teams', 'doctor', '--json'], undefined, hint, remoteEnv(device.platform));
211
- const res = sshExec(target, remoteCmd, { timeoutMs: 30000, multiplex: true });
292
+ const res = sshExec(target, remoteCmd, { timeoutMs: 30000, multiplex: true, extraSshArgs });
212
293
  if (res.code === 0) {
213
294
  try {
214
295
  const map = JSON.parse(res.stdout);
@@ -221,10 +302,19 @@ export function probeDevice(device, opts) {
221
302
  let installedVersions;
222
303
  if (opts?.withVersions) {
223
304
  const viewCmd = buildRemoteAgentsInvocation(['view', '--json'], undefined, hint, remoteEnv(device.platform));
224
- const vres = sshExec(target, viewCmd, { timeoutMs: 30000, multiplex: true });
305
+ const vres = sshExec(target, viewCmd, { timeoutMs: 30000, multiplex: true, extraSshArgs });
225
306
  if (vres.code === 0)
226
307
  installedVersions = parseInstalledVersions(vres.stdout);
227
308
  }
309
+ let remoteBundles;
310
+ if (opts?.withSecrets) {
311
+ // Metadata only — `secrets list --json` returns names + timestamps and never
312
+ // values, which is why this is safe to run across the fleet.
313
+ const listCmd = buildRemoteAgentsInvocation(['secrets', 'list', '--json'], undefined, hint, remoteEnv(device.platform));
314
+ const lres = sshExec(target, listCmd, { timeoutMs: 30000, multiplex: true, extraSshArgs });
315
+ if (lres.code === 0)
316
+ remoteBundles = parseRemoteBundles(lres.stdout);
317
+ }
228
318
  return {
229
319
  device: device.name,
230
320
  reachable: true,
@@ -232,8 +322,52 @@ export function probeDevice(device, opts) {
232
322
  cliVersion: ready.version ?? undefined,
233
323
  installedAgents: installed,
234
324
  installedVersions,
325
+ remoteBundles,
235
326
  };
236
327
  }
328
+ /**
329
+ * Narrow a remote `secrets list --json` payload to `name -> updated_at`.
330
+ *
331
+ * Exported and pure so the parse is unit-tested against real payload shapes with
332
+ * no live fleet. Returns `{}` rather than throwing on anything unexpected: the
333
+ * remote runs its own agents-cli version, and a parse failure must degrade to
334
+ * "unknown, so push" — never to "present, so skip", which would silently leave a
335
+ * device unprovisioned.
336
+ */
337
+ export function parseRemoteBundles(stdout) {
338
+ try {
339
+ const parsed = JSON.parse(stdout);
340
+ const rows = Array.isArray(parsed)
341
+ ? parsed
342
+ : Array.isArray(parsed?.bundles)
343
+ ? parsed.bundles
344
+ : [];
345
+ // Null-prototype: a remote-supplied name is used as a KEY here, so `{}` would
346
+ // let `__proto__` hit the prototype setter instead of becoming an own
347
+ // property (and then read back as absent). It also means the presence check
348
+ // cannot see inherited names.
349
+ const out = Object.create(null);
350
+ for (const row of rows) {
351
+ if (!row || typeof row !== 'object')
352
+ continue;
353
+ const r = row;
354
+ const name = typeof r.name === 'string' ? r.name : undefined;
355
+ if (!name)
356
+ continue;
357
+ // `updatedAt` is the real field name in `secrets list --json` — verified
358
+ // against a live payload, not assumed. `updated_at` is accepted too so an
359
+ // older remote is not silently recorded with an empty timestamp.
360
+ const ts = typeof r.updatedAt === 'string' ? r.updatedAt
361
+ : typeof r.updated_at === 'string' ? r.updated_at
362
+ : '';
363
+ out[name] = ts;
364
+ }
365
+ return out;
366
+ }
367
+ catch {
368
+ return {};
369
+ }
370
+ }
237
371
  /** Execute one device's planned actions in order. Real SSH — no mocks. */
238
372
  export function reconcileDevice(row, device, ctx) {
239
373
  if (!row.probe.reachable) {
@@ -249,12 +383,13 @@ export function reconcileDevice(row, device, ctx) {
249
383
  }
250
384
  const hint = osHint(device.platform);
251
385
  const env = remoteEnv(device.platform);
386
+ const extraSshArgs = deviceIdentityArgs(device);
252
387
  let ok = true;
253
- const sshAgents = (args, input) => sshExec(target, buildRemoteAgentsInvocation(args, undefined, hint, env), { timeoutMs: 300000, multiplex: true, input });
388
+ const sshAgents = (args, input) => sshExec(target, buildRemoteAgentsInvocation(args, undefined, hint, env), { timeoutMs: 300000, multiplex: true, input, extraSshArgs });
254
389
  // 1. agents-cli install/upgrade.
255
390
  const cliAction = row.actions.find((a) => a.kind === 'install-cli' || a.kind === 'upgrade-cli');
256
391
  if (cliAction) {
257
- const r = bootstrapAgentsCli(target, ctx.targetCliVersion, hint);
392
+ const r = bootstrapAgentsCli(target, ctx.targetCliVersion, hint, extraSshArgs);
258
393
  steps.push({ kind: cliAction.kind, ok: r.ok, detail: cliAction.detail });
259
394
  ok = ok && r.ok;
260
395
  }
@@ -290,6 +425,50 @@ export function reconcileDevice(row, device, ctx) {
290
425
  steps.push({ kind: 'push-login', ok: r.code === 0, detail: `propagate login: ${pushAgents.join(', ')}` });
291
426
  ok = ok && r.code === 0;
292
427
  }
428
+ // 5. secrets provisioning — LAST, and deliberately so. It is the most
429
+ // sensitive mutation apply performs (credential VALUES crossing to another
430
+ // machine), so every lower-risk step above is already recorded before we
431
+ // touch it: a failure here never obscures what did land.
432
+ //
433
+ // Resolve ONCE per device even for several bundles is not possible (a resolve
434
+ // is per bundle), but each bundle resolves once and pushes once — the read can
435
+ // prompt, so it must not repeat.
436
+ const pushSecrets = row.actions.filter((a) => a.kind === 'push-secret');
437
+ for (const action of pushSecrets) {
438
+ const bundle = action.bundle;
439
+ if (!bundle) {
440
+ // A push-secret action without a bundle name is a planner bug, not a
441
+ // recoverable state — fail loud rather than push nothing and report ok.
442
+ steps.push({ kind: 'push-secret', ok: false, detail: 'push-secret action carried no bundle name' });
443
+ ok = false;
444
+ continue;
445
+ }
446
+ const backend = device.platform === 'linux' ? 'file' : 'keychain';
447
+ try {
448
+ const out = pushBundleToHost(bundle, target, {
449
+ remoteBackend: backend,
450
+ operation: `fleet apply ${row.device}`,
451
+ // No passphrase, ever, from this path. On the file backend the remote
452
+ // auto-provisions its OWN machine-local key, which is the entire point:
453
+ // each box gets an unshared at-rest key instead of the fleet-wide shared
454
+ // secret RUSH-1968 is about.
455
+ });
456
+ steps.push({
457
+ kind: 'push-secret',
458
+ ok: out.ok,
459
+ detail: out.ok
460
+ ? `secrets '${bundle}' -> ${row.device} (${backend}): ${out.message}`
461
+ : `secrets '${bundle}' -> ${row.device}: ${out.message}`,
462
+ });
463
+ ok = ok && out.ok;
464
+ }
465
+ catch (e) {
466
+ // A local resolve failure (locked store, missing bundle, multi-line value)
467
+ // is reported per bundle rather than aborting the whole device.
468
+ steps.push({ kind: 'push-secret', ok: false, detail: `secrets '${bundle}': ${e.message}` });
469
+ ok = false;
470
+ }
471
+ }
293
472
  // Surface blocked logins as (non-fatal) informational steps.
294
473
  for (const blocked of row.loginBlocked) {
295
474
  steps.push({ kind: 'needs-login', ok: false, detail: `${blocked} needs a manual login (\`agents ssh ${row.device} -- ${blocked}\`)` });
@@ -44,6 +44,7 @@ export interface PendingLogin {
44
44
  platform?: string;
45
45
  /** ssh dial target for the device. */
46
46
  target: string;
47
+ extraSshArgs?: string[];
47
48
  flow: LoginFlow;
48
49
  /** True when a device-code flow this command can actually drive. */
49
50
  remotable: boolean;
@@ -75,7 +76,7 @@ export declare function selectLoginTargets(devices: DeviceProfile[], agents: (Ag
75
76
  * devices need the askpass shim env, which the PTY env allowlist strips — those
76
77
  * are a follow-up (TODO), surfaced as an error rather than silently mis-driven.
77
78
  */
78
- export declare function buildRemoteLoginSshCommand(target: string, flow: LoginFlow): string;
79
+ export declare function buildRemoteLoginSshCommand(target: string, flow: LoginFlow, extraSshArgs?: string[]): string;
79
80
  export type LoginMode = 'bulk' | 'interactive';
80
81
  /** Per-pair status shape shared between the driver, the status endpoint, and the page. */
81
82
  export type LoginStatusState = 'pending' | 'driving' | 'ready' | 'authorized' | 'error' | 'skipped';
@@ -138,7 +139,7 @@ export interface DriveResult extends ScrapedLogin {
138
139
  * in the browser; the caller polls the credential file for completion. Testable
139
140
  * against a fake {@link PtyDriver}.
140
141
  */
141
- export declare function driveRemoteLogin(target: string, flow: LoginFlow, driver: PtyDriver, opts?: DriveOptions): Promise<DriveResult>;
142
+ export declare function driveRemoteLogin(target: string, flow: LoginFlow, driver: PtyDriver, opts?: DriveOptions, extraSshArgs?: string[]): Promise<DriveResult>;
142
143
  export interface DetectOptions {
143
144
  agents?: (AgentId | string)[];
144
145
  devices?: string[];
@@ -157,7 +158,7 @@ export declare function detectPending(opts?: DetectOptions): Promise<PendingLogi
157
158
  * bumping after the human completes the browser step. POSIX `stat` only (the
158
159
  * device-code agents are all POSIX-fleet).
159
160
  */
160
- export declare function remoteFileMtime(target: string, homeRel: string): Promise<number>;
161
+ export declare function remoteFileMtime(target: string, homeRel: string, extraSshArgs?: string[]): Promise<number>;
161
162
  export interface RunFleetLoginOptions {
162
163
  agents?: (AgentId | string)[];
163
164
  devices?: string[];
@@ -29,7 +29,7 @@
29
29
  import * as http from 'http';
30
30
  import { isControlDevice } from '../devices/registry.js';
31
31
  import { loadDevices } from '../devices/registry.js';
32
- import { fleetDialTarget } from '../devices/connect.js';
32
+ import { deviceIdentityArgs, fleetDialTarget } from '../devices/connect.js';
33
33
  import { planFleetTargets } from '../devices/fleet.js';
34
34
  import { assertValidSshTarget, shellQuote, sshExecAsync } from '../ssh-exec.js';
35
35
  import { machineId } from '../session/sync/config.js';
@@ -134,6 +134,7 @@ export function selectLoginTargets(devices, agents, cache, opts = {}) {
134
134
  agent,
135
135
  platform: device.platform,
136
136
  target: fleetDialTarget(device),
137
+ extraSshArgs: deviceIdentityArgs(device),
137
138
  flow,
138
139
  remotable,
139
140
  reason,
@@ -152,7 +153,7 @@ export function selectLoginTargets(devices, agents, cache, opts = {}) {
152
153
  * devices need the askpass shim env, which the PTY env allowlist strips — those
153
154
  * are a follow-up (TODO), surfaced as an error rather than silently mis-driven.
154
155
  */
155
- export function buildRemoteLoginSshCommand(target, flow) {
156
+ export function buildRemoteLoginSshCommand(target, flow, extraSshArgs = []) {
156
157
  assertValidSshTarget(target);
157
158
  // The agent CLIs (kimi/droid/codex/…) are agents-cli shims that live in
158
159
  // ~/.agents/.cache/shims and are only on PATH in an interactive/login shell.
@@ -165,6 +166,7 @@ export function buildRemoteLoginSshCommand(target, flow) {
165
166
  'ssh', '-tt',
166
167
  '-o', 'StrictHostKeyChecking=accept-new',
167
168
  '-o', 'ConnectTimeout=10',
169
+ ...extraSshArgs.map(shellQuote),
168
170
  shellQuote(target),
169
171
  shellQuote(remoteCmd),
170
172
  ].join(' ');
@@ -367,7 +369,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
367
369
  * in the browser; the caller polls the credential file for completion. Testable
368
370
  * against a fake {@link PtyDriver}.
369
371
  */
370
- export async function driveRemoteLogin(target, flow, driver, opts = {}) {
372
+ export async function driveRemoteLogin(target, flow, driver, opts = {}, extraSshArgs = []) {
371
373
  const initialDelayMs = opts.initialDelayMs ?? 4000;
372
374
  const pollMs = opts.pollMs ?? 1000;
373
375
  const timeoutMs = opts.timeoutMs ?? 90000;
@@ -377,7 +379,7 @@ export async function driveRemoteLogin(target, flow, driver, opts = {}) {
377
379
  // `ssh -tt` process it drives) leaks until the sidecar's idle reaper. On the
378
380
  // normal return paths the caller owns stop() via the returned sessionId.
379
381
  try {
380
- await driver.exec(id, buildRemoteLoginSshCommand(target, flow));
382
+ await driver.exec(id, buildRemoteLoginSshCommand(target, flow, extraSshArgs));
381
383
  await sleep(initialDelayMs);
382
384
  if (flow.deviceCodeSelect) {
383
385
  await driver.write(id, flow.deviceCodeSelect);
@@ -431,9 +433,9 @@ export async function detectPending(opts = {}) {
431
433
  * bumping after the human completes the browser step. POSIX `stat` only (the
432
434
  * device-code agents are all POSIX-fleet).
433
435
  */
434
- export async function remoteFileMtime(target, homeRel) {
436
+ export async function remoteFileMtime(target, homeRel, extraSshArgs = []) {
435
437
  const cmd = `stat -c %Y "$HOME/${homeRel}" 2>/dev/null || stat -f %m "$HOME/${homeRel}" 2>/dev/null || echo 0`;
436
- const res = await sshExecAsync(target, cmd, { timeoutMs: 15000, multiplex: true }).catch(() => null);
438
+ const res = await sshExecAsync(target, cmd, { timeoutMs: 15000, multiplex: true, extraSshArgs }).catch(() => null);
437
439
  if (!res || res.code !== 0)
438
440
  return 0;
439
441
  const n = parseInt(res.stdout.trim(), 10);
@@ -507,10 +509,10 @@ export async function runFleetLogin(opts = {}) {
507
509
  const k = key(p.device, p.agent);
508
510
  const st = statuses.get(k);
509
511
  st.state = 'driving';
510
- const baseMtime = await remoteFileMtime(p.target, p.flow.successFile).catch(() => 0);
512
+ const baseMtime = await remoteFileMtime(p.target, p.flow.successFile, p.extraSshArgs).catch(() => 0);
511
513
  let sessionId;
512
514
  try {
513
- const r = await driveRemoteLogin(p.target, p.flow, driver, opts.drive);
515
+ const r = await driveRemoteLogin(p.target, p.flow, driver, opts.drive, p.extraSshArgs);
514
516
  sessionId = r.sessionId;
515
517
  if (r.url && r.code) {
516
518
  st.url = r.url;
@@ -539,7 +541,7 @@ export async function runFleetLogin(opts = {}) {
539
541
  st.detail = 'device code expired before authorization';
540
542
  break;
541
543
  }
542
- const mtime = await remoteFileMtime(p.target, p.flow.successFile).catch(() => 0);
544
+ const mtime = await remoteFileMtime(p.target, p.flow.successFile, p.extraSshArgs).catch(() => 0);
543
545
  if (mtime > baseMtime) {
544
546
  st.state = 'authorized';
545
547
  break;
@@ -95,13 +95,28 @@ export interface DeviceProbe {
95
95
  * undefined, version-pinned specs fall back to id-level presence.
96
96
  */
97
97
  installedVersions?: Record<string, string[]>;
98
+ /**
99
+ * Secrets bundles already present on the device: name -> `updated_at` (or ''
100
+ * when the remote reports none). Only populated when the manifest declares
101
+ * bundles AND `--provision-secrets` is set — a fleet that uses no bundles
102
+ * never pays for the extra round trip.
103
+ *
104
+ * METADATA ONLY. `agents secrets list --json` returns names and timestamps and
105
+ * explicitly never values, which is what makes this probe safe to run.
106
+ */
107
+ remoteBundles?: Record<string, string>;
98
108
  /** Reason string when `reachable` is false or the probe partially failed. */
99
109
  note?: string;
100
110
  }
101
111
  /** One planned action against a device, in a single reconcile dimension. */
102
112
  export type FleetActionKind = 'install-cli' | 'upgrade-cli' | 'add-agent' | 'sync-config' | 'push-login' | 'needs-login'
103
- /** Secrets bundles the profile declares but that can't be pushed (values are
104
- * keychain-local) surfaced as a manual recreate, like `needs-login`. */
113
+ /** Push a declared secrets bundle to the device over SSH. Opt-in only
114
+ * (`--provision-secrets`) and gated on a pinned host key, because this moves
115
+ * credential VALUES to another machine (RUSH-1968). */
116
+ | 'push-secret'
117
+ /** A declared secrets bundle that could NOT be pushed — the flag is off, the
118
+ * host key isn't pinned, or the bundle is already current. Surfaced as a manual
119
+ * recreate, like `needs-login`. */
105
120
  | 'needs-secret';
106
121
  export interface FleetAction {
107
122
  device: string;
@@ -111,6 +126,10 @@ export interface FleetAction {
111
126
  /** Full agent spec for `add-agent` (e.g. `claude@2.1.170`) so the plan can show
112
127
  * the exact version being installed; equals the id for a bare/latest spec. */
113
128
  spec?: string;
129
+ /** Bundle name for `push-secret` / `needs-secret`, so the executor pushes the
130
+ * bundle the planner decided on rather than re-deriving it from the detail
131
+ * string. */
132
+ bundle?: string;
114
133
  /** Human, one-line description of the action. */
115
134
  detail: string;
116
135
  }
@@ -1,4 +1,3 @@
1
1
  export declare function readGeminiSettings(settingsPath: string): Record<string, unknown>;
2
2
  export declare function writeGeminiSettings(settingsPath: string, settings: Record<string, unknown>): void;
3
3
  export declare function updateGeminiSettings(settingsPath: string, mutate: (settings: Record<string, unknown>) => void): Record<string, unknown>;
4
- export declare function setGeminiAutoUpdateDisabled(settings: Record<string, unknown>): void;
@@ -1,3 +1,15 @@
1
+ /**
2
+ * Generic reader/writer for a `.gemini/…/settings.json`-shaped JSON config.
3
+ *
4
+ * Despite the name, this module is no longer gemini-specific: gemini itself
5
+ * is hard-deprecated and has no live caller left (its own settings writer,
6
+ * `generateGeminiConfig`, was removed with the RUSH-2202 runner.ts fix — a
7
+ * gemini routine is rejected before it ever reaches sandbox prep). Antigravity
8
+ * nests its own `settings.json` under the same `.gemini/antigravity-cli/` tree
9
+ * and shares this exact shape, so `permissions.ts` reuses `updateGeminiSettings`
10
+ * for antigravity's live permission writes. Keep the generic helpers; do not
11
+ * reintroduce gemini-only logic here.
12
+ */
1
13
  import * as fs from 'fs';
2
14
  import * as path from 'path';
3
15
  function isRecord(value) {
@@ -24,10 +36,3 @@ export function updateGeminiSettings(settingsPath, mutate) {
24
36
  writeGeminiSettings(settingsPath, settings);
25
37
  return settings;
26
38
  }
27
- export function setGeminiAutoUpdateDisabled(settings) {
28
- const general = isRecord(settings.general) ? settings.general : {};
29
- settings.general = {
30
- ...general,
31
- enableAutoUpdate: false,
32
- };
33
- }
@@ -505,6 +505,21 @@ if [ "$CACHE_STATUS" = miss ]; then
505
505
  if [ "$_in_backoff" -eq 0 ]; then
506
506
  # Fix 1: lockdir — only one background refresh runs at a time.
507
507
  LOCK_DIR="$CACHE_FILE.bg.lck"
508
+ # Fix 4 (RUSH-2259): reclaim a stale lock. The lock is released by the bg
509
+ # subshell's EXIT trap, but a hard kill (SIGKILL, OOM, reboot) skips the
510
+ # trap and orphans the dir — after which every future mkdir fails and bg
511
+ # refresh stops FOREVER while stale cache is served indefinitely. The dir
512
+ # mtime is fixed at mkdir time (the refresh writes elsewhere), so its age
513
+ # is the time the lock has been held; treat a lock older than LOCK_TTL_SEC
514
+ # as abandoned and remove it before locking. LOCK_TTL_SEC is set well above
515
+ # any real hook runtime so a live refresh is never reclaimed out from under.
516
+ LOCK_TTL_SEC=300
517
+ if [ -d "$LOCK_DIR" ]; then
518
+ _lock_mtime=$("$PY" -c 'import os,sys; print(int(os.path.getmtime(sys.argv[1])))' "$LOCK_DIR" 2>/dev/null || echo 0)
519
+ _lock_now_s=$(date +%s)
520
+ _lock_age=$((_lock_now_s - \${_lock_mtime:-0}))
521
+ [ "$_lock_age" -ge "$LOCK_TTL_SEC" ] && rm -rf "$LOCK_DIR" 2>/dev/null
522
+ fi
508
523
  if mkdir "$LOCK_DIR" 2>/dev/null; then
509
524
  tmp="$CACHE_FILE.new.$$"
510
525
  # Fix 3: background subshell captures and logs its own real exit code.
@@ -217,12 +217,15 @@ export declare function listCentralHooks(): HookEntry[];
217
217
  *
218
218
  * A bare number stays seconds (`timeout: 30` → 30) for backward compatibility.
219
219
  * A Go-style duration string is parsed into seconds: `5s`, `2m`, `1h30m`,
220
- * `90s`, `1h`. This intentionally does NOT reuse {@link parseTimeout} from
221
- * routines.ts that one returns milliseconds, has no seconds (`s`) unit, and
222
- * floors at one minute, none of which fit hook timeouts (typically 5–600s).
220
+ * `90s`, `1h`. Suffixed durations longer than 24 hours are rejected as likely
221
+ * typos, while bare seconds stay uncapped for backward compatibility. This
222
+ * intentionally does NOT reuse {@link parseTimeout} from routines.ts that one
223
+ * returns milliseconds, has no seconds (`s`) unit, and floors at one minute,
224
+ * none of which fit hook timeouts (typically 5–600s).
223
225
  *
224
- * Returns the seconds value, or `null` when the input is not a positive number
225
- * or a parseable duration string — the caller decides how to surface that.
226
+ * Returns the seconds value, or `null` when the input is not a positive number,
227
+ * is not parseable, or is a suffixed duration longer than 24 hours — the caller
228
+ * decides how to surface that.
226
229
  */
227
230
  export declare function normalizeHookTimeoutSeconds(value: unknown): number | null;
228
231
  /**
package/dist/lib/hooks.js CHANGED
@@ -1160,17 +1160,21 @@ export function listCentralHooks() {
1160
1160
  }
1161
1161
  return results;
1162
1162
  }
1163
+ const MAX_HOOK_DURATION_SECONDS = 24 * 60 * 60;
1163
1164
  /**
1164
1165
  * Normalize a hook `timeout` from agents.yaml into a whole number of seconds.
1165
1166
  *
1166
1167
  * A bare number stays seconds (`timeout: 30` → 30) for backward compatibility.
1167
1168
  * A Go-style duration string is parsed into seconds: `5s`, `2m`, `1h30m`,
1168
- * `90s`, `1h`. This intentionally does NOT reuse {@link parseTimeout} from
1169
- * routines.ts that one returns milliseconds, has no seconds (`s`) unit, and
1170
- * floors at one minute, none of which fit hook timeouts (typically 5–600s).
1169
+ * `90s`, `1h`. Suffixed durations longer than 24 hours are rejected as likely
1170
+ * typos, while bare seconds stay uncapped for backward compatibility. This
1171
+ * intentionally does NOT reuse {@link parseTimeout} from routines.ts that one
1172
+ * returns milliseconds, has no seconds (`s`) unit, and floors at one minute,
1173
+ * none of which fit hook timeouts (typically 5–600s).
1171
1174
  *
1172
- * Returns the seconds value, or `null` when the input is not a positive number
1173
- * or a parseable duration string — the caller decides how to surface that.
1175
+ * Returns the seconds value, or `null` when the input is not a positive number,
1176
+ * is not parseable, or is a suffixed duration longer than 24 hours — the caller
1177
+ * decides how to surface that.
1174
1178
  */
1175
1179
  export function normalizeHookTimeoutSeconds(value) {
1176
1180
  if (typeof value === 'number') {
@@ -1194,7 +1198,7 @@ export function normalizeHookTimeoutSeconds(value) {
1194
1198
  const minutes = Number(m[4] || 0);
1195
1199
  const seconds = Number(m[5] || 0);
1196
1200
  const total = ((weeks * 7 + days) * 24 + hours) * 3600 + minutes * 60 + seconds;
1197
- return total > 0 ? total : null;
1201
+ return total > 0 && total <= MAX_HOOK_DURATION_SECONDS ? total : null;
1198
1202
  }
1199
1203
  return null;
1200
1204
  }