@phnx-labs/agents-cli 1.20.63 → 1.20.65

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 (197) hide show
  1. package/CHANGELOG.md +56 -3
  2. package/README.md +46 -2
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.d.ts +12 -0
  5. package/dist/commands/apply.js +274 -0
  6. package/dist/commands/browser.js +2 -2
  7. package/dist/commands/cloud.js +32 -2
  8. package/dist/commands/doctor.js +4 -1
  9. package/dist/commands/exec.js +149 -77
  10. package/dist/commands/feed.d.ts +4 -0
  11. package/dist/commands/feed.js +52 -19
  12. package/dist/commands/hosts.js +44 -6
  13. package/dist/commands/lease.d.ts +23 -0
  14. package/dist/commands/lease.js +201 -0
  15. package/dist/commands/mailboxes.d.ts +20 -0
  16. package/dist/commands/mailboxes.js +390 -0
  17. package/dist/commands/mcp.js +55 -5
  18. package/dist/commands/monitors.d.ts +12 -0
  19. package/dist/commands/monitors.js +740 -0
  20. package/dist/commands/output.js +2 -2
  21. package/dist/commands/routines.js +43 -16
  22. package/dist/commands/secrets.d.ts +16 -0
  23. package/dist/commands/secrets.js +215 -64
  24. package/dist/commands/serve.js +31 -0
  25. package/dist/commands/sessions-export.d.ts +2 -0
  26. package/dist/commands/sessions-export.js +284 -0
  27. package/dist/commands/sessions-import.d.ts +2 -0
  28. package/dist/commands/sessions-import.js +230 -0
  29. package/dist/commands/sessions.d.ts +16 -0
  30. package/dist/commands/sessions.js +40 -6
  31. package/dist/commands/ssh.js +143 -5
  32. package/dist/commands/usage.d.ts +2 -0
  33. package/dist/commands/usage.js +7 -2
  34. package/dist/commands/versions.js +7 -3
  35. package/dist/commands/view.d.ts +27 -1
  36. package/dist/commands/view.js +32 -9
  37. package/dist/commands/webhook.js +10 -2
  38. package/dist/index.js +35 -14
  39. package/dist/lib/agents.d.ts +36 -0
  40. package/dist/lib/agents.js +80 -20
  41. package/dist/lib/auto-dispatch-provider.js +7 -2
  42. package/dist/lib/auto-dispatch.d.ts +3 -0
  43. package/dist/lib/auto-dispatch.js +3 -0
  44. package/dist/lib/browser/chrome.js +2 -2
  45. package/dist/lib/browser/drivers/ssh.js +19 -2
  46. package/dist/lib/cloud/antigravity.js +2 -2
  47. package/dist/lib/cloud/host.d.ts +59 -0
  48. package/dist/lib/cloud/host.js +224 -0
  49. package/dist/lib/cloud/registry.js +4 -0
  50. package/dist/lib/cloud/types.d.ts +6 -4
  51. package/dist/lib/comms-render.d.ts +37 -0
  52. package/dist/lib/comms-render.js +89 -0
  53. package/dist/lib/computer-rpc.js +3 -1
  54. package/dist/lib/crabbox/cli.d.ts +72 -0
  55. package/dist/lib/crabbox/cli.js +162 -9
  56. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  57. package/dist/lib/crabbox/runtimes.js +35 -2
  58. package/dist/lib/daemon.d.ts +20 -4
  59. package/dist/lib/daemon.js +68 -20
  60. package/dist/lib/devices/fleet.d.ts +3 -2
  61. package/dist/lib/devices/fleet.js +9 -0
  62. package/dist/lib/devices/health.d.ts +77 -0
  63. package/dist/lib/devices/health.js +186 -0
  64. package/dist/lib/devices/registry.d.ts +15 -0
  65. package/dist/lib/devices/registry.js +9 -0
  66. package/dist/lib/exec.d.ts +19 -2
  67. package/dist/lib/exec.js +41 -13
  68. package/dist/lib/fleet/apply.d.ts +63 -0
  69. package/dist/lib/fleet/apply.js +214 -0
  70. package/dist/lib/fleet/auth-sync.d.ts +67 -0
  71. package/dist/lib/fleet/auth-sync.js +142 -0
  72. package/dist/lib/fleet/manifest.d.ts +29 -0
  73. package/dist/lib/fleet/manifest.js +127 -0
  74. package/dist/lib/fleet/types.d.ts +129 -0
  75. package/dist/lib/fleet/types.js +13 -0
  76. package/dist/lib/git.d.ts +27 -0
  77. package/dist/lib/git.js +34 -2
  78. package/dist/lib/hosts/dispatch.d.ts +29 -8
  79. package/dist/lib/hosts/dispatch.js +46 -18
  80. package/dist/lib/hosts/passthrough.js +2 -0
  81. package/dist/lib/hosts/providers/devices.d.ts +27 -0
  82. package/dist/lib/hosts/providers/devices.js +98 -0
  83. package/dist/lib/hosts/registry.d.ts +10 -16
  84. package/dist/lib/hosts/registry.js +17 -50
  85. package/dist/lib/hosts/remote-cmd.d.ts +23 -0
  86. package/dist/lib/hosts/remote-cmd.js +71 -0
  87. package/dist/lib/hosts/run-target.d.ts +84 -0
  88. package/dist/lib/hosts/run-target.js +99 -0
  89. package/dist/lib/hosts/types.d.ts +23 -5
  90. package/dist/lib/hosts/types.js +22 -4
  91. package/dist/lib/linear-autoclose.d.ts +30 -0
  92. package/dist/lib/linear-autoclose.js +22 -0
  93. package/dist/lib/mailbox.d.ts +39 -0
  94. package/dist/lib/mailbox.js +112 -0
  95. package/dist/lib/mcp.d.ts +27 -1
  96. package/dist/lib/mcp.js +126 -12
  97. package/dist/lib/monitors/config.d.ts +161 -0
  98. package/dist/lib/monitors/config.js +372 -0
  99. package/dist/lib/monitors/dispatch.d.ts +28 -0
  100. package/dist/lib/monitors/dispatch.js +91 -0
  101. package/dist/lib/monitors/engine.d.ts +61 -0
  102. package/dist/lib/monitors/engine.js +201 -0
  103. package/dist/lib/monitors/sources/command.d.ts +11 -0
  104. package/dist/lib/monitors/sources/command.js +31 -0
  105. package/dist/lib/monitors/sources/device.d.ts +13 -0
  106. package/dist/lib/monitors/sources/device.js +35 -0
  107. package/dist/lib/monitors/sources/file.d.ts +14 -0
  108. package/dist/lib/monitors/sources/file.js +57 -0
  109. package/dist/lib/monitors/sources/http.d.ts +10 -0
  110. package/dist/lib/monitors/sources/http.js +34 -0
  111. package/dist/lib/monitors/sources/index.d.ts +14 -0
  112. package/dist/lib/monitors/sources/index.js +31 -0
  113. package/dist/lib/monitors/sources/poll.d.ts +9 -0
  114. package/dist/lib/monitors/sources/poll.js +9 -0
  115. package/dist/lib/monitors/sources/types.d.ts +18 -0
  116. package/dist/lib/monitors/sources/types.js +9 -0
  117. package/dist/lib/monitors/sources/webhook.d.ts +23 -0
  118. package/dist/lib/monitors/sources/webhook.js +47 -0
  119. package/dist/lib/monitors/sources/ws.d.ts +14 -0
  120. package/dist/lib/monitors/sources/ws.js +45 -0
  121. package/dist/lib/monitors/state.d.ts +69 -0
  122. package/dist/lib/monitors/state.js +144 -0
  123. package/dist/lib/paths.d.ts +13 -0
  124. package/dist/lib/paths.js +26 -4
  125. package/dist/lib/platform/exec.d.ts +16 -0
  126. package/dist/lib/platform/exec.js +17 -0
  127. package/dist/lib/plugins.js +101 -2
  128. package/dist/lib/redact.d.ts +14 -1
  129. package/dist/lib/redact.js +47 -1
  130. package/dist/lib/remote-agents-json.js +7 -1
  131. package/dist/lib/rotate.d.ts +6 -3
  132. package/dist/lib/rotate.js +0 -1
  133. package/dist/lib/routines.d.ts +37 -2
  134. package/dist/lib/routines.js +54 -12
  135. package/dist/lib/runner.d.ts +1 -0
  136. package/dist/lib/runner.js +348 -13
  137. package/dist/lib/sandbox.d.ts +9 -1
  138. package/dist/lib/sandbox.js +11 -2
  139. package/dist/lib/secrets/agent.d.ts +48 -10
  140. package/dist/lib/secrets/agent.js +123 -15
  141. package/dist/lib/secrets/bundles.d.ts +26 -0
  142. package/dist/lib/secrets/bundles.js +59 -8
  143. package/dist/lib/secrets/mcp.js +4 -2
  144. package/dist/lib/secrets/remote.d.ts +17 -0
  145. package/dist/lib/secrets/remote.js +40 -0
  146. package/dist/lib/self-update.d.ts +20 -0
  147. package/dist/lib/self-update.js +54 -1
  148. package/dist/lib/serve/control.d.ts +95 -0
  149. package/dist/lib/serve/control.js +260 -0
  150. package/dist/lib/serve/server.d.ts +35 -1
  151. package/dist/lib/serve/server.js +106 -76
  152. package/dist/lib/serve/stream.d.ts +43 -0
  153. package/dist/lib/serve/stream.js +116 -0
  154. package/dist/lib/serve/token.d.ts +35 -0
  155. package/dist/lib/serve/token.js +85 -0
  156. package/dist/lib/session/bundle.d.ts +164 -0
  157. package/dist/lib/session/bundle.js +200 -0
  158. package/dist/lib/session/remote-bundle.d.ts +12 -0
  159. package/dist/lib/session/remote-bundle.js +61 -0
  160. package/dist/lib/session/remote-list.js +5 -1
  161. package/dist/lib/session/state.d.ts +7 -25
  162. package/dist/lib/session/state.js +16 -6
  163. package/dist/lib/session/sync/agents.d.ts +54 -6
  164. package/dist/lib/session/sync/agents.js +0 -0
  165. package/dist/lib/session/sync/config.js +8 -2
  166. package/dist/lib/session/sync/manifest.d.ts +14 -3
  167. package/dist/lib/session/sync/manifest.js +4 -0
  168. package/dist/lib/session/sync/sync.d.ts +23 -2
  169. package/dist/lib/session/sync/sync.js +177 -74
  170. package/dist/lib/session/types.d.ts +30 -0
  171. package/dist/lib/ssh-tunnel.d.ts +19 -1
  172. package/dist/lib/ssh-tunnel.js +99 -8
  173. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  174. package/dist/lib/staleness/detectors/subagents.js +5 -192
  175. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  176. package/dist/lib/staleness/writers/subagents.js +11 -102
  177. package/dist/lib/startup/command-registry.d.ts +4 -0
  178. package/dist/lib/startup/command-registry.js +9 -0
  179. package/dist/lib/state.d.ts +5 -0
  180. package/dist/lib/state.js +12 -0
  181. package/dist/lib/subagents-registry.d.ts +85 -0
  182. package/dist/lib/subagents-registry.js +393 -0
  183. package/dist/lib/subagents.d.ts +8 -8
  184. package/dist/lib/subagents.js +32 -663
  185. package/dist/lib/sync-umbrella.d.ts +1 -0
  186. package/dist/lib/sync-umbrella.js +14 -3
  187. package/dist/lib/tmux/session.d.ts +7 -0
  188. package/dist/lib/tmux/session.js +3 -1
  189. package/dist/lib/triggers/webhook.d.ts +18 -0
  190. package/dist/lib/triggers/webhook.js +105 -0
  191. package/dist/lib/types.d.ts +35 -1
  192. package/dist/lib/usage.d.ts +42 -3
  193. package/dist/lib/usage.js +163 -21
  194. package/dist/lib/versions.js +14 -11
  195. package/dist/lib/workflows.d.ts +20 -0
  196. package/dist/lib/workflows.js +24 -0
  197. package/package.json +2 -1
@@ -63,6 +63,7 @@ export interface UmbrellaResult {
63
63
  pushed: number;
64
64
  pulled: number;
65
65
  merged: number;
66
+ error?: string;
66
67
  };
67
68
  devices?: {
68
69
  synced: number;
@@ -118,9 +118,20 @@ export async function runUmbrellaSync(args) {
118
118
  const { isSyncConfigured } = await import('./session/sync/config.js');
119
119
  if (isBetaEnabled('session-sync') && isSyncConfigured()) {
120
120
  const { syncSessions } = await import('./session/sync/sync.js');
121
- const r = await syncSessions();
122
- result.sessions = { ran: true, pushed: r.pushed, pulled: r.pulled, merged: r.merged };
123
- log(`sessions: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}`);
121
+ // Record a failure here instead of letting it throw: like the fetchSecrets
122
+ // block above, a failure in one fetch stage must not abort the others or
123
+ // the reconcile below (see this function's doc comment). Without this,
124
+ // syncSessions() throwing — e.g. via the C1 containment reject on a
125
+ // malicious peer manifest — would skip `plan.reconcile` entirely.
126
+ try {
127
+ const r = await syncSessions();
128
+ result.sessions = { ran: true, pushed: r.pushed, pulled: r.pulled, merged: r.merged };
129
+ log(`sessions: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}`);
130
+ }
131
+ catch (err) {
132
+ result.sessions = { ran: false, pushed: 0, pulled: 0, merged: 0, error: err.message };
133
+ log(`sessions: failed — ${err.message}`);
134
+ }
124
135
  }
125
136
  else {
126
137
  result.sessions = { ran: false, pushed: 0, pulled: 0, merged: 0 };
@@ -33,6 +33,13 @@ export interface SessionMeta {
33
33
  export interface CreateSessionOptions {
34
34
  name: string;
35
35
  cmd?: string;
36
+ /**
37
+ * Redacted copy of `cmd` to persist in SessionMeta.cmd instead of the real
38
+ * command. When set, `cmd` is still executed but only `metaCmd` is written to
39
+ * disk — used to keep resolved secret values out of the informational cmd
40
+ * field (see exec.ts buildTmuxAgentCommand, RUSH-1758). Defaults to `cmd`.
41
+ */
42
+ metaCmd?: string;
36
43
  cwd?: string;
37
44
  env?: NodeJS.ProcessEnv;
38
45
  socket?: string;
@@ -106,7 +106,9 @@ export async function createSession(opts) {
106
106
  name: opts.name,
107
107
  socket,
108
108
  createdAt: Date.now(),
109
- cmd: opts.cmd,
109
+ // Persist the redacted copy when provided so resolved secret values in the
110
+ // launch command never land on disk (RUSH-1758).
111
+ cmd: opts.metaCmd ?? opts.cmd,
110
112
  cwd: opts.cwd,
111
113
  source: opts.source ?? 'cli',
112
114
  labels: opts.labels,
@@ -99,6 +99,18 @@ export interface DeliveryStore {
99
99
  markJob(id: string, jobName: string): void;
100
100
  }
101
101
  export declare function createMemoryDeliveryStore(maxEntries?: number): DeliveryStore;
102
+ /**
103
+ * A durable, disk-backed delivery store. Unlike `createMemoryDeliveryStore`,
104
+ * seen delivery ids survive a process restart and are bounded by AGE, not by a
105
+ * fixed entry count — so a captured valid delivery cannot re-fire after a
106
+ * restart or after count-based LRU eviction would have dropped it.
107
+ *
108
+ * `retentionMs` doubles as the replay-acceptance window: a delivery whose id is
109
+ * still on record (younger than the window) is rejected as a duplicate; entries
110
+ * older than the window are pruned (keeping the file bounded) since a webhook
111
+ * source will not legitimately retry a delivery that old.
112
+ */
113
+ export declare function createFileDeliveryStore(filePath: string, retentionMs?: number): DeliveryStore;
102
114
  export interface RateLimiter {
103
115
  take(key: string): boolean;
104
116
  }
@@ -117,6 +129,12 @@ export interface WebhookServerOptions {
117
129
  rateLimiter?: RateLimiter;
118
130
  rateLimitPerMinute?: number;
119
131
  maxBodyBytes?: number;
132
+ /** Per-IP ingress throttle applied BEFORE the body read (bad-sig flood guard). */
133
+ ipRateLimiter?: RateLimiter;
134
+ /** Per-source-IP requests/minute allowed through to the body read. Default 120. */
135
+ ipRateLimitPerMinute?: number;
136
+ /** Max concurrent TCP connections the receiver accepts. Default 256. */
137
+ maxConnections?: number;
120
138
  }
121
139
  /**
122
140
  * Start a localhost-bound receiver. It accepts only:
@@ -12,7 +12,9 @@
12
12
  * verification, idempotency, source allow-listing, and rate limiting.
13
13
  */
14
14
  import * as crypto from 'crypto';
15
+ import * as fs from 'fs';
15
16
  import * as http from 'http';
17
+ import * as path from 'path';
16
18
  import { jobRunsOnThisDevice, listJobs } from '../routines.js';
17
19
  import { executeJobDetached } from '../runner.js';
18
20
  /** Read `repository.full_name` (`owner/name`) from a webhook payload, if present. */
@@ -236,6 +238,88 @@ export function createMemoryDeliveryStore(maxEntries = 1000) {
236
238
  },
237
239
  };
238
240
  }
241
+ /**
242
+ * A durable, disk-backed delivery store. Unlike `createMemoryDeliveryStore`,
243
+ * seen delivery ids survive a process restart and are bounded by AGE, not by a
244
+ * fixed entry count — so a captured valid delivery cannot re-fire after a
245
+ * restart or after count-based LRU eviction would have dropped it.
246
+ *
247
+ * `retentionMs` doubles as the replay-acceptance window: a delivery whose id is
248
+ * still on record (younger than the window) is rejected as a duplicate; entries
249
+ * older than the window are pruned (keeping the file bounded) since a webhook
250
+ * source will not legitimately retry a delivery that old.
251
+ */
252
+ export function createFileDeliveryStore(filePath, retentionMs = 14 * 24 * 60 * 60 * 1000) {
253
+ const seen = new Map();
254
+ // Load persisted state (best-effort: a corrupt/missing file starts empty).
255
+ try {
256
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
257
+ const loadedAt = Date.now();
258
+ for (const [id, entry] of Object.entries(raw)) {
259
+ if (typeof entry?.updatedAt !== 'number' || loadedAt - entry.updatedAt > retentionMs)
260
+ continue;
261
+ seen.set(id, {
262
+ complete: entry.complete === true,
263
+ jobs: new Set(Array.isArray(entry.jobs) ? entry.jobs : []),
264
+ updatedAt: entry.updatedAt,
265
+ });
266
+ }
267
+ }
268
+ catch {
269
+ // no prior file / unreadable — start empty
270
+ }
271
+ const persist = () => {
272
+ const snapshot = {};
273
+ for (const [id, entry] of seen) {
274
+ snapshot[id] = { complete: entry.complete, jobs: [...entry.jobs], updatedAt: entry.updatedAt };
275
+ }
276
+ try {
277
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
278
+ const tmp = `${filePath}.${process.pid}.tmp`;
279
+ fs.writeFileSync(tmp, JSON.stringify(snapshot), 'utf-8');
280
+ fs.renameSync(tmp, filePath);
281
+ }
282
+ catch {
283
+ // best-effort durability; an unwritable dir must not crash ingress
284
+ }
285
+ };
286
+ const prune = (now) => {
287
+ for (const [id, entry] of seen) {
288
+ if (now - entry.updatedAt > retentionMs)
289
+ seen.delete(id);
290
+ }
291
+ };
292
+ const touch = (id) => {
293
+ const now = Date.now();
294
+ prune(now);
295
+ let current = seen.get(id);
296
+ if (!current) {
297
+ current = { complete: false, jobs: new Set(), updatedAt: now };
298
+ seen.set(id, current);
299
+ }
300
+ current.updatedAt = now;
301
+ return current;
302
+ };
303
+ return {
304
+ seen: (id) => {
305
+ const entry = seen.get(id);
306
+ if (!entry)
307
+ return false;
308
+ if (Date.now() - entry.updatedAt > retentionMs)
309
+ return false;
310
+ return entry.complete === true;
311
+ },
312
+ mark: (id) => {
313
+ touch(id).complete = true;
314
+ persist();
315
+ },
316
+ completedJobs: (id) => new Set(seen.get(id)?.jobs ?? []),
317
+ markJob: (id, jobName) => {
318
+ touch(id).jobs.add(jobName);
319
+ persist();
320
+ },
321
+ };
322
+ }
239
323
  export function createMemoryRateLimiter(limit, windowMs) {
240
324
  const buckets = new Map();
241
325
  return {
@@ -285,6 +369,7 @@ async function readRawBody(req, maxBytes) {
285
369
  export function startWebhookServer(options) {
286
370
  const deliveryStore = options.deliveryStore ?? createMemoryDeliveryStore();
287
371
  const rateLimiter = options.rateLimiter ?? createMemoryRateLimiter(options.rateLimitPerMinute ?? 60, 60_000);
372
+ const ipRateLimiter = options.ipRateLimiter ?? createMemoryRateLimiter(options.ipRateLimitPerMinute ?? 120, 60_000);
288
373
  const maxBodyBytes = options.maxBodyBytes ?? 1024 * 1024;
289
374
  const server = http.createServer((req, res) => {
290
375
  void (async () => {
@@ -305,6 +390,23 @@ export function startWebhookServer(options) {
305
390
  res.end(JSON.stringify({ ok: false, error: `missing ${source} webhook secret` }));
306
391
  return;
307
392
  }
393
+ // Per-IP throttle + declared-size cap BEFORE the (expensive) body read +
394
+ // HMAC. A bad-signature flood of 1 MiB POSTs must be rejected without
395
+ // forcing a full body read and an HMAC per request — the signed-delivery
396
+ // rate limit further down runs only after a signature passes, so it can't
397
+ // shed this load on its own.
398
+ const ip = req.socket.remoteAddress ?? 'unknown';
399
+ if (!ipRateLimiter.take(ip)) {
400
+ res.writeHead(429, { 'content-type': 'application/json' });
401
+ res.end(JSON.stringify({ ok: false, error: 'rate limit exceeded' }));
402
+ return;
403
+ }
404
+ const declaredLength = Number.parseInt(header(req.headers, 'content-length') ?? '', 10);
405
+ if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
406
+ res.writeHead(413, { 'content-type': 'application/json' });
407
+ res.end(JSON.stringify({ ok: false, error: `payload exceeds ${maxBodyBytes} bytes` }));
408
+ return;
409
+ }
308
410
  try {
309
411
  const rawBody = await readRawBody(req, maxBodyBytes);
310
412
  const valid = source === 'github'
@@ -357,6 +459,9 @@ export function startWebhookServer(options) {
357
459
  }
358
460
  })();
359
461
  });
462
+ // Connection cap: bound how many concurrent TCP connections the receiver
463
+ // will hold open, so a flood cannot exhaust file descriptors / memory.
464
+ server.maxConnections = options.maxConnections ?? 256;
360
465
  server.listen(options.port ?? 0, options.host ?? '127.0.0.1');
361
466
  return server;
362
467
  }
@@ -515,6 +515,20 @@ export interface PluginManifest {
515
515
  userConfig?: PluginUserConfigField[];
516
516
  /** Other plugin names this plugin depends on. Missing deps produce a warning. */
517
517
  dependencies?: string[];
518
+ /**
519
+ * Inline hook configuration (or a path to a hooks JSON file) declared directly
520
+ * in the manifest, per the official plugin format — an execution surface even
521
+ * when the plugin ships no `hooks/` directory. Untyped because the shape is a
522
+ * path string or an inline event map; capability detection only needs to know
523
+ * whether it is present and non-empty.
524
+ */
525
+ hooks?: unknown;
526
+ /**
527
+ * Inline MCP-server configuration (or a path to an MCP JSON file) declared
528
+ * directly in the manifest — an execution surface even when the plugin ships
529
+ * no `.mcp.json`. Untyped for the same reason as `hooks`.
530
+ */
531
+ mcpServers?: unknown;
518
532
  }
519
533
  /** A plugin found on disk with its parsed manifest and resource inventory. */
520
534
  export interface DiscoveredPlugin {
@@ -624,15 +638,28 @@ export interface ExtraRepoConfig {
624
638
  export interface Meta {
625
639
  agents?: Partial<Record<AgentId, string>>;
626
640
  run?: RunConfig;
641
+ /**
642
+ * `agents run --lease` config. `secretsBundle` names the keychain secrets bundle
643
+ * whose provider token (e.g. `HCLOUD_TOKEN`) crabbox uses to reach the cloud API.
644
+ * When unset, the bundle is resolved by env (`AGENTS_LEASE_SECRETS_BUNDLE`) then
645
+ * auto-detected (the first bundle that declares a provider token key).
646
+ */
647
+ lease?: {
648
+ secretsBundle?: string;
649
+ };
627
650
  /** macOS secrets-agent config. `policy` is the default prompt policy for
628
651
  * bundles without an explicit per-bundle policy: `daily` (the default) asks
629
652
  * once per ~7 days, `always` asks every time. `auto` (default on) lets the
630
653
  * first real keychain read of a `daily` bundle populate the broker so
631
- * concurrent runs read silently — set it `false` to force a prompt on every read. */
654
+ * concurrent runs read silently — set it `false` to force a prompt on every read.
655
+ * `holdMs` caps how long an unlocked/auto-cached bundle is held before the next
656
+ * read re-prompts (default 7 days; e.g. 86400000 for a 24h cap). Clamped to
657
+ * [1 minute, 30 days]. */
632
658
  secrets?: {
633
659
  policy?: 'always' | 'daily';
634
660
  agent?: {
635
661
  auto?: boolean;
662
+ holdMs?: number;
636
663
  };
637
664
  };
638
665
  /** Spend guardrails (issue #346). User-global caps; project agents.yaml overrides. */
@@ -689,6 +716,13 @@ export interface Meta {
689
716
  * are never copied. `inline` hosts carry their own address/user.
690
717
  */
691
718
  hosts?: Record<string, HostEntry>;
719
+ /**
720
+ * Declarative fleet profile (`agents apply` / `ag apply`). Additive to the
721
+ * schema — project `agents:` version-pins are untouched. Declares which agents
722
+ * every device should have, which config to sync, and how login propagates.
723
+ * Full shape in `lib/fleet/types.ts` (FleetManifest).
724
+ */
725
+ fleet?: import('./fleet/types.js').FleetManifest;
692
726
  }
693
727
  /** Persisted agent-host entry in agents.yaml (overlay or inline). */
694
728
  export interface HostEntry {
@@ -1,7 +1,7 @@
1
- import type { AccountInfo } from './agents.js';
1
+ import { type AccountInfo } from './agents.js';
2
2
  import type { AgentId } from './types.js';
3
3
  /** Discriminator for usage window types. */
4
- export type UsageWindowKey = 'session' | 'week' | 'sonnet_week';
4
+ export type UsageWindowKey = 'session' | 'week' | 'sonnet_week' | 'month';
5
5
  /** A single rate-limit window with utilization percentage and reset time. */
6
6
  export interface UsageWindow {
7
7
  key: UsageWindowKey;
@@ -154,7 +154,46 @@ export interface KimiUsagesResponse {
154
154
  export declare function normalizeKimiWindows(data: KimiUsagesResponse): UsageWindow[];
155
155
  /** Derive a display plan label from Kimi's membership tier or subscription type. */
156
156
  export declare function formatKimiPlan(data: KimiUsagesResponse): string | null;
157
- /** Load Claude OAuth credentials from the system keychain/keyring. */
157
+ /** A single Droid token-rate-limit window from /api/billing/limits. */
158
+ interface DroidLimitWindow {
159
+ usedPercent?: number | null;
160
+ windowEnd?: string | null;
161
+ }
162
+ /** Response shape from Factory's billing limits endpoint (subset we render). */
163
+ export interface DroidBillingLimitsResponse {
164
+ usesTokenRateLimitsBilling?: boolean | null;
165
+ limits?: {
166
+ standard?: {
167
+ fiveHour?: DroidLimitWindow | null;
168
+ weekly?: DroidLimitWindow | null;
169
+ monthly?: DroidLimitWindow | null;
170
+ } | null;
171
+ } | null;
172
+ }
173
+ /**
174
+ * Normalize the Factory billing-limits payload into the common UsageWindow
175
+ * shape. Orgs on the legacy (non token-rate-limit) billing model have no
176
+ * meaningful windows, so they render nothing — mirrors droid's own gate on
177
+ * `usesTokenRateLimitsBilling` before it reads `limits.standard`.
178
+ */
179
+ export declare function normalizeDroidWindows(data: DroidBillingLimitsResponse): UsageWindow[];
180
+ /**
181
+ * Load a version home's Claude OAuth credential from the two stores Claude Code
182
+ * uses, tried in order:
183
+ *
184
+ * 1. The OS keychain (`getKeychainToken`). Canonical on macOS — Claude Code
185
+ * writes the token to the login keychain and we read it via `/usr/bin/security`.
186
+ * 2. `<home>/.claude/.credentials.json`. On a headless Linux box (the
187
+ * `agents view --host <linux>` case) there is no reachable Secret Service, so
188
+ * the Claude CLI stores its OAuth token in this plaintext file instead. The
189
+ * keychain read above finds nothing on that platform, so we fall back to the
190
+ * file. Same wrapped `{ claudeAiOauth }` shape, so one parser handles both.
191
+ * Mirrors `readClaudeCredentialsBlob` (cloud/rush.ts), the proven pattern.
192
+ *
193
+ * Without step 2 the live usage fetch got no token on Linux, so `agents view`
194
+ * (run remotely over SSH by `--host`) rendered no usage bars even though the
195
+ * account + plan — read from the plaintext `.claude.json` — showed fine.
196
+ */
158
197
  export declare function loadClaudeOauth(home?: string): Promise<ClaudeOauthCredentials | null>;
159
198
  /**
160
199
  * Derive the Keychain service name for a Claude home directory.
package/dist/lib/usage.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Usage and rate-limit tracking for Claude and Codex agents.
2
+ * Usage and rate-limit tracking for Claude, Codex, Kimi, and Droid agents.
3
3
  *
4
- * Fetches live usage data from the Anthropic OAuth API (Claude) or parses
4
+ * Fetches live usage data from each agent's usage API (Anthropic OAuth for
5
+ * Claude, Kimi Code /usages, Factory billing limits for Droid) or parses
5
6
  * rate-limit events from Codex session logs. Results are normalized into a
6
7
  * common UsageSnapshot shape, cached to disk, and rendered as terminal
7
8
  * progress bars for the `agents view` command.
@@ -14,6 +15,7 @@ import * as path from 'path';
14
15
  import * as readline from 'readline';
15
16
  import { promisify } from 'util';
16
17
  import chalk from 'chalk';
18
+ import { decodeJwtPayload, decryptDroidAuthPayload } from './agents.js';
17
19
  import { walkForFiles } from './fs-walk.js';
18
20
  import { getKeychainToken, setKeychainToken, deleteKeychainToken, } from './secrets/index.js';
19
21
  import { getCacheDir } from './state.js';
@@ -34,6 +36,7 @@ const CLAUDE_KEYCHAIN_SERVICE = 'Claude Code-credentials';
34
36
  const getClaudeUsageCachePath = () => path.join(getCacheDir(), 'claude-usage.json');
35
37
  const CACHED_CLAUDE_USAGE_SOURCE_LABEL = 'last seen live account data';
36
38
  const KIMI_USAGES_URL = 'https://api.kimi.com/coding/v1/usages';
39
+ const DROID_USAGE_URL = 'https://api.factory.ai/api/billing/limits';
37
40
  const COMPACT_BAR_LEN = 5;
38
41
  const USAGE_BAR_LEN = 10;
39
42
  const FULL = '\u2588';
@@ -47,6 +50,8 @@ export async function getUsageInfo(agentId, options) {
47
50
  return getCodexUsageInfo(options);
48
51
  case 'kimi':
49
52
  return getKimiUsageInfo(options);
53
+ case 'droid':
54
+ return getDroidUsageInfo(options);
50
55
  default:
51
56
  return { snapshot: null, error: null };
52
57
  }
@@ -117,13 +122,14 @@ const inFlightRefreshes = new Map();
117
122
  */
118
123
  export async function getUsageInfoForIdentity(input) {
119
124
  const usageKey = getUsageLookupKey(input.info);
120
- // Agents whose usage comes from a live network call (Claude, Kimi) go through
121
- // the stale-while-revalidate cache below so `agents run`/`agents view` stay off
122
- // the network on the hot path. Everything else (Codex reads local session
123
- // logs) takes the legacy blocking path. The on-disk cache is shared and keyed
124
- // by usageKey, which is namespaced per agent (`claude:org=…`, `kimi:user=…`),
125
- // so one cache file holds every account without collision.
126
- const usesNetworkUsage = input.agentId === 'claude' || input.agentId === 'kimi';
125
+ // Agents whose usage comes from a live network call (Claude, Kimi, Droid) go
126
+ // through the stale-while-revalidate cache below so `agents run`/`agents view`
127
+ // stay off the network on the hot path. Everything else (Codex reads local
128
+ // session logs) takes the legacy blocking path. The on-disk cache is shared and
129
+ // keyed by usageKey, which is namespaced per agent (`claude:org=…`,
130
+ // `kimi:user=…`, `droid:org=…`), so one cache file holds every account without
131
+ // collision.
132
+ const usesNetworkUsage = input.agentId === 'claude' || input.agentId === 'kimi' || input.agentId === 'droid';
127
133
  if (!usesNetworkUsage || !usageKey) {
128
134
  return getUsageInfo(input.agentId, {
129
135
  home: input.home,
@@ -201,6 +207,12 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3) {
201
207
  parts.push(chalk.gray(plan.padEnd(planWidth)));
202
208
  }
203
209
  if (snapshot) {
210
+ // Compact rows show every BLOCKING window — the same set
211
+ // deriveUsageStatusFromSnapshot uses for the rate-limited badge — so an
212
+ // account throttled by its month window (Droid meters on 5h/week/month)
213
+ // shows the bar that explains why. Claude's Sonnet week is a per-model
214
+ // sub-limit, not a blocking window; it renders only in the full
215
+ // per-version usage section.
204
216
  const windows = snapshot.windows
205
217
  .filter((window) => window.key !== 'sonnet_week')
206
218
  .map((window) => `${chalk.gray(`${window.shortLabel}:`)} ${renderCompactUsageBar(window.usedPercent)}`);
@@ -513,6 +525,95 @@ export function formatKimiPlan(data) {
513
525
  return null;
514
526
  return tail.charAt(0).toUpperCase() + tail.slice(1).toLowerCase();
515
527
  }
528
+ /**
529
+ * Fetch Droid usage via Factory's billing limits API — the same endpoint the
530
+ * droid CLI polls for its token-limit banner. The WorkOS access token comes
531
+ * from the locally decrypted ~/.factory/auth.v2.file (the same credential
532
+ * account identity in agents.ts reads).
533
+ *
534
+ * Deliberately NO token refresh, for a sharper reason than Kimi's: WorkOS
535
+ * refresh tokens are single-use and rotate on every exchange, so refreshing
536
+ * here would race a concurrently running droid session and can permanently
537
+ * invalidate the user's login chain. Droid refreshes its own credential when
538
+ * it runs; if the stored token is expired we skip the live fetch and let the
539
+ * SWR cache serve the last-seen snapshot.
540
+ */
541
+ async function getDroidUsageInfo(options) {
542
+ try {
543
+ const cred = decryptDroidAuthPayload(options?.home || os.homedir());
544
+ const accessToken = cred?.access_token;
545
+ if (typeof accessToken !== 'string' || !accessToken) {
546
+ return { snapshot: null, error: null };
547
+ }
548
+ const exp = decodeJwtPayload(accessToken)?.exp;
549
+ if (typeof exp === 'number' && Date.now() / 1000 >= exp) {
550
+ return { snapshot: null, error: null };
551
+ }
552
+ const response = await fetch(DROID_USAGE_URL, {
553
+ method: 'GET',
554
+ headers: {
555
+ Authorization: `Bearer ${accessToken}`,
556
+ Accept: 'application/json',
557
+ },
558
+ signal: AbortSignal.timeout(5000),
559
+ });
560
+ // 401 => revoked/expired token; render nothing rather than a misleading
561
+ // empty bar.
562
+ if (!response.ok) {
563
+ return { snapshot: null, error: null };
564
+ }
565
+ const data = await response.json();
566
+ const windows = normalizeDroidWindows(data);
567
+ if (windows.length === 0) {
568
+ return { snapshot: null, error: null };
569
+ }
570
+ return {
571
+ snapshot: {
572
+ source: 'live',
573
+ sourceLabel: 'live account data',
574
+ capturedAt: new Date(),
575
+ windows,
576
+ },
577
+ error: null,
578
+ };
579
+ }
580
+ catch {
581
+ return { snapshot: null, error: null };
582
+ }
583
+ }
584
+ /**
585
+ * Normalize the Factory billing-limits payload into the common UsageWindow
586
+ * shape. Orgs on the legacy (non token-rate-limit) billing model have no
587
+ * meaningful windows, so they render nothing — mirrors droid's own gate on
588
+ * `usesTokenRateLimitsBilling` before it reads `limits.standard`.
589
+ */
590
+ export function normalizeDroidWindows(data) {
591
+ if (data.usesTokenRateLimitsBilling !== true)
592
+ return [];
593
+ const standard = data.limits?.standard;
594
+ if (!standard)
595
+ return [];
596
+ const windows = [
597
+ normalizeDroidWindow(standard.fiveHour, 'session', 'Current session', 'S'),
598
+ normalizeDroidWindow(standard.weekly, 'week', 'Current week', 'W'),
599
+ normalizeDroidWindow(standard.monthly, 'month', 'Current month', 'M'),
600
+ ];
601
+ return windows.filter((window) => window !== null);
602
+ }
603
+ /** Normalize a single Droid billing-limits window. */
604
+ function normalizeDroidWindow(window, key, label, shortLabel) {
605
+ const usedPercent = normalizePercent(window?.usedPercent);
606
+ if (usedPercent === null)
607
+ return null;
608
+ return {
609
+ key,
610
+ label,
611
+ shortLabel,
612
+ usedPercent,
613
+ resetsAt: parseDateValue(window?.windowEnd),
614
+ windowMinutes: inferWindowMinutes(key),
615
+ };
616
+ }
516
617
  /** Collect Codex JSONL session files sorted newest-first. */
517
618
  function collectCodexSessionFiles(home) {
518
619
  const base = home || os.homedir();
@@ -609,19 +710,16 @@ function normalizeClaudeWindow(window, key, label, shortLabel) {
609
710
  windowMinutes: inferWindowMinutes(key),
610
711
  };
611
712
  }
612
- /** Load Claude OAuth credentials from the system keychain/keyring. */
613
- export async function loadClaudeOauth(home) {
614
- // Windows not yet supported
615
- if (process.platform !== 'darwin' && process.platform !== 'linux') {
616
- return null;
617
- }
713
+ /**
714
+ * Parse a wrapped Claude OAuth payload — the `{ claudeAiOauth, organizationUuid }`
715
+ * shape written by BOTH the macOS Keychain item and the Linux `.credentials.json`
716
+ * file into our credential struct. Returns null when there is no usable access
717
+ * token. Never throws (malformed JSON => null).
718
+ */
719
+ function parseClaudeOauthPayload(raw) {
618
720
  try {
619
- const service = getClaudeKeychainService(home);
620
- const stdout = getKeychainToken(service);
621
- const payload = JSON.parse(stdout.trim());
622
- if (typeof payload?.claudeAiOauth?.accessToken !== 'string')
623
- return null;
624
- if (!payload.claudeAiOauth) {
721
+ const payload = JSON.parse(raw.trim());
722
+ if (!payload?.claudeAiOauth || typeof payload.claudeAiOauth.accessToken !== 'string') {
625
723
  return null;
626
724
  }
627
725
  return {
@@ -633,6 +731,48 @@ export async function loadClaudeOauth(home) {
633
731
  return null;
634
732
  }
635
733
  }
734
+ /**
735
+ * Load a version home's Claude OAuth credential from the two stores Claude Code
736
+ * uses, tried in order:
737
+ *
738
+ * 1. The OS keychain (`getKeychainToken`). Canonical on macOS — Claude Code
739
+ * writes the token to the login keychain and we read it via `/usr/bin/security`.
740
+ * 2. `<home>/.claude/.credentials.json`. On a headless Linux box (the
741
+ * `agents view --host <linux>` case) there is no reachable Secret Service, so
742
+ * the Claude CLI stores its OAuth token in this plaintext file instead. The
743
+ * keychain read above finds nothing on that platform, so we fall back to the
744
+ * file. Same wrapped `{ claudeAiOauth }` shape, so one parser handles both.
745
+ * Mirrors `readClaudeCredentialsBlob` (cloud/rush.ts), the proven pattern.
746
+ *
747
+ * Without step 2 the live usage fetch got no token on Linux, so `agents view`
748
+ * (run remotely over SSH by `--host`) rendered no usage bars even though the
749
+ * account + plan — read from the plaintext `.claude.json` — showed fine.
750
+ */
751
+ export async function loadClaudeOauth(home) {
752
+ // The OS keychain/keyring step is macOS/Linux-only. Windows skips straight
753
+ // to the .credentials.json fallback — the Claude CLI has no keychain
754
+ // integration there and stores its OAuth token in that file too.
755
+ if (process.platform === 'darwin' || process.platform === 'linux') {
756
+ try {
757
+ const fromKeychain = parseClaudeOauthPayload(getKeychainToken(getClaudeKeychainService(home)));
758
+ if (fromKeychain)
759
+ return fromKeychain;
760
+ }
761
+ catch {
762
+ // No keychain item, or no reachable keyring (headless Linux) — fall through.
763
+ }
764
+ }
765
+ const credsPath = path.join(home ?? os.homedir(), '.claude', '.credentials.json');
766
+ try {
767
+ if (fs.existsSync(credsPath)) {
768
+ return parseClaudeOauthPayload(fs.readFileSync(credsPath, 'utf-8'));
769
+ }
770
+ }
771
+ catch {
772
+ // Unreadable file — treat as not signed in.
773
+ }
774
+ return null;
775
+ }
636
776
  /**
637
777
  * Save Claude OAuth credentials to the system keychain/keyring.
638
778
  * Reads the existing payload, merges the new OAuth fields, and writes back.
@@ -895,6 +1035,8 @@ function inferWindowMinutes(key) {
895
1035
  case 'week':
896
1036
  case 'sonnet_week':
897
1037
  return 10080;
1038
+ case 'month':
1039
+ return 43200;
898
1040
  }
899
1041
  }
900
1042
  /** Parse a date value from a number (epoch seconds or ms) or ISO string. */
@@ -30,7 +30,7 @@ import { listResources } from './resources.js';
30
30
  import { VERSION_RE, compareVersions } from './agent-spec/primitives.js';
31
31
  import { AGENTS, agentConfigDirName, getAccountEmail, resolveAgentName, formatAgentError, findInPath, isSelfUpdatingAgent } from './agents.js';
32
32
  import { discoverPermissionGroups, getActivePermissionPresetName, readPermissionPresetRecipe, PERMISSION_PRESET_ENV_VAR } from './permissions.js';
33
- import { parseMcpServerConfig } from './mcp.js';
33
+ import { parseMcpServerConfig, isProjectMcpTrusted } from './mcp.js';
34
34
  import { createVersionedAlias, removeVersionedAlias, getConfigSymlinkVersion, ensureClaudeInsideSymlink } from './shims.js';
35
35
  import { importInstallScriptBinary } from './import.js';
36
36
  import { IS_WINDOWS, composeWin32CommandLine } from './platform/index.js';
@@ -2511,20 +2511,23 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2511
2511
  // For Claude/Codex: uses CLI commands (claude mcp add, codex mcp add)
2512
2512
  // For others: edits config files directly
2513
2513
  //
2514
- // Mirror the hooks defense: exclude project-scoped MCPs from the sync. An
2515
- // MCP server is an executable invoked under the agent's authority, so a
2516
- // cloned public repo's .agents/mcp/foo.yaml could install an arbitrary
2517
- // command. We pre-compute the set of project-scoped names and drop them
2518
- // before handing the list to installMcpServers. (The deeper helper-side
2519
- // dedup in lib/mcp.ts still lets a project entry shadow a same-named
2520
- // user entry, so name-collision shadowing is not fully closed here
2521
- // tracked separately for a follow-up in lib/mcp.ts.)
2522
- const projectScopedMcpNames = new Set(getScopedMcpResources(cwd).filter(r => r.scope === 'project').map(r => r.name));
2514
+ // Mirror the hooks defense: an MCP server is an executable invoked under the
2515
+ // agent's authority, so a cloned public repo's .agents/mcp/foo.yaml could
2516
+ // install an arbitrary command (RUSH-1776). Project-scoped MCPs are UNTRUSTED
2517
+ // by default drop them before handing the list to installMcpServers unless
2518
+ // the user has explicitly trusted this project (`agents mcp trust`). The
2519
+ // register/spawn choke in lib/mcp.ts (getMcpServersByName) enforces the same
2520
+ // trust gate and drops untrusted project entries before dedup, so a project
2521
+ // entry can no longer shadow a same-named user entry either.
2522
+ const projectMcpTrusted = projectAgentsDir ? isProjectMcpTrusted(projectAgentsDir) : false;
2523
+ const untrustedProjectMcpNames = new Set(projectMcpTrusted
2524
+ ? []
2525
+ : getScopedMcpResources(cwd).filter(r => r.scope === 'project').map(r => r.name));
2523
2526
  const mcpWriter = getWriter('mcp', agent);
2524
2527
  const mcpToSyncAll = selection
2525
2528
  ? resolveSelection(selection.mcp, available.mcp)
2526
2529
  : (mcpWriter ? available.mcp : []);
2527
- const mcpToSync = mcpToSyncAll.filter(n => !projectScopedMcpNames.has(n));
2530
+ const mcpToSync = mcpToSyncAll.filter(n => !untrustedProjectMcpNames.has(n));
2528
2531
  if (mcpToSync.length > 0 && mcpWriter) {
2529
2532
  const r = mcpWriter.write({ version, versionHome, selection: mcpToSync, cwd });
2530
2533
  result.mcp = r.synced;