@phnx-labs/agents-cli 1.20.64 → 1.20.66

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 (188) hide show
  1. package/CHANGELOG.md +51 -3
  2. package/README.md +156 -3
  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.d.ts +48 -0
  10. package/dist/commands/exec.js +159 -49
  11. package/dist/commands/feed.js +25 -11
  12. package/dist/commands/hosts.js +44 -6
  13. package/dist/commands/mcp.js +55 -5
  14. package/dist/commands/monitors.d.ts +12 -0
  15. package/dist/commands/monitors.js +748 -0
  16. package/dist/commands/output.js +2 -2
  17. package/dist/commands/plugins.js +28 -7
  18. package/dist/commands/routines.js +23 -2
  19. package/dist/commands/secrets.d.ts +16 -0
  20. package/dist/commands/secrets.js +215 -64
  21. package/dist/commands/serve.js +31 -0
  22. package/dist/commands/sessions-browser.d.ts +82 -0
  23. package/dist/commands/sessions-browser.js +320 -0
  24. package/dist/commands/sessions-export.js +8 -3
  25. package/dist/commands/sessions.d.ts +17 -0
  26. package/dist/commands/sessions.js +157 -10
  27. package/dist/commands/share.d.ts +2 -0
  28. package/dist/commands/share.js +150 -0
  29. package/dist/commands/ssh.js +54 -3
  30. package/dist/commands/versions.js +7 -3
  31. package/dist/commands/view.d.ts +26 -0
  32. package/dist/commands/view.js +32 -9
  33. package/dist/commands/webhook.js +10 -2
  34. package/dist/index.js +35 -14
  35. package/dist/lib/agents.d.ts +46 -0
  36. package/dist/lib/agents.js +121 -3
  37. package/dist/lib/auto-dispatch-provider.js +7 -2
  38. package/dist/lib/auto-dispatch.d.ts +3 -0
  39. package/dist/lib/auto-dispatch.js +3 -0
  40. package/dist/lib/browser/chrome.js +2 -2
  41. package/dist/lib/cloud/antigravity.js +2 -2
  42. package/dist/lib/cloud/host.d.ts +59 -0
  43. package/dist/lib/cloud/host.js +224 -0
  44. package/dist/lib/cloud/registry.js +4 -0
  45. package/dist/lib/cloud/types.d.ts +6 -4
  46. package/dist/lib/computer-rpc.js +3 -1
  47. package/dist/lib/crabbox/cli.js +5 -1
  48. package/dist/lib/crabbox/runtimes.js +11 -2
  49. package/dist/lib/daemon.d.ts +20 -4
  50. package/dist/lib/daemon.js +62 -19
  51. package/dist/lib/devices/connect.d.ts +18 -1
  52. package/dist/lib/devices/connect.js +10 -2
  53. package/dist/lib/devices/fleet.d.ts +3 -2
  54. package/dist/lib/devices/fleet.js +9 -0
  55. package/dist/lib/devices/known-hosts.d.ts +62 -0
  56. package/dist/lib/devices/known-hosts.js +137 -0
  57. package/dist/lib/devices/registry.d.ts +15 -0
  58. package/dist/lib/devices/registry.js +9 -0
  59. package/dist/lib/exec.d.ts +19 -2
  60. package/dist/lib/exec.js +41 -13
  61. package/dist/lib/fleet/apply.d.ts +63 -0
  62. package/dist/lib/fleet/apply.js +214 -0
  63. package/dist/lib/fleet/auth-sync.d.ts +67 -0
  64. package/dist/lib/fleet/auth-sync.js +142 -0
  65. package/dist/lib/fleet/manifest.d.ts +29 -0
  66. package/dist/lib/fleet/manifest.js +127 -0
  67. package/dist/lib/fleet/types.d.ts +129 -0
  68. package/dist/lib/fleet/types.js +13 -0
  69. package/dist/lib/git.d.ts +27 -0
  70. package/dist/lib/git.js +34 -2
  71. package/dist/lib/hosts/dispatch.d.ts +29 -8
  72. package/dist/lib/hosts/dispatch.js +66 -20
  73. package/dist/lib/hosts/passthrough.js +2 -0
  74. package/dist/lib/hosts/providers/devices.d.ts +27 -0
  75. package/dist/lib/hosts/providers/devices.js +98 -0
  76. package/dist/lib/hosts/registry.d.ts +10 -16
  77. package/dist/lib/hosts/registry.js +17 -50
  78. package/dist/lib/hosts/remote-cmd.d.ts +23 -0
  79. package/dist/lib/hosts/remote-cmd.js +79 -4
  80. package/dist/lib/hosts/run-target.d.ts +84 -0
  81. package/dist/lib/hosts/run-target.js +99 -0
  82. package/dist/lib/hosts/types.d.ts +23 -5
  83. package/dist/lib/hosts/types.js +22 -4
  84. package/dist/lib/linear-autoclose.d.ts +30 -0
  85. package/dist/lib/linear-autoclose.js +22 -0
  86. package/dist/lib/mcp.d.ts +27 -1
  87. package/dist/lib/mcp.js +126 -12
  88. package/dist/lib/monitors/config.d.ts +161 -0
  89. package/dist/lib/monitors/config.js +372 -0
  90. package/dist/lib/monitors/dispatch.d.ts +28 -0
  91. package/dist/lib/monitors/dispatch.js +91 -0
  92. package/dist/lib/monitors/engine.d.ts +61 -0
  93. package/dist/lib/monitors/engine.js +205 -0
  94. package/dist/lib/monitors/sources/command.d.ts +11 -0
  95. package/dist/lib/monitors/sources/command.js +31 -0
  96. package/dist/lib/monitors/sources/device.d.ts +13 -0
  97. package/dist/lib/monitors/sources/device.js +45 -0
  98. package/dist/lib/monitors/sources/file.d.ts +14 -0
  99. package/dist/lib/monitors/sources/file.js +57 -0
  100. package/dist/lib/monitors/sources/http.d.ts +10 -0
  101. package/dist/lib/monitors/sources/http.js +34 -0
  102. package/dist/lib/monitors/sources/index.d.ts +14 -0
  103. package/dist/lib/monitors/sources/index.js +31 -0
  104. package/dist/lib/monitors/sources/poll.d.ts +9 -0
  105. package/dist/lib/monitors/sources/poll.js +9 -0
  106. package/dist/lib/monitors/sources/types.d.ts +18 -0
  107. package/dist/lib/monitors/sources/types.js +9 -0
  108. package/dist/lib/monitors/sources/webhook.d.ts +23 -0
  109. package/dist/lib/monitors/sources/webhook.js +47 -0
  110. package/dist/lib/monitors/sources/ws.d.ts +14 -0
  111. package/dist/lib/monitors/sources/ws.js +45 -0
  112. package/dist/lib/monitors/state.d.ts +69 -0
  113. package/dist/lib/monitors/state.js +144 -0
  114. package/dist/lib/picker.d.ts +53 -0
  115. package/dist/lib/picker.js +214 -1
  116. package/dist/lib/platform/exec.d.ts +16 -0
  117. package/dist/lib/platform/exec.js +17 -0
  118. package/dist/lib/plugins.d.ts +31 -1
  119. package/dist/lib/plugins.js +175 -15
  120. package/dist/lib/redact.d.ts +14 -1
  121. package/dist/lib/redact.js +47 -1
  122. package/dist/lib/remote-agents-json.js +7 -1
  123. package/dist/lib/rotate.d.ts +6 -3
  124. package/dist/lib/rotate.js +0 -1
  125. package/dist/lib/routines.d.ts +16 -0
  126. package/dist/lib/routines.js +19 -0
  127. package/dist/lib/runner.d.ts +1 -0
  128. package/dist/lib/runner.js +102 -9
  129. package/dist/lib/secrets/agent.d.ts +83 -10
  130. package/dist/lib/secrets/agent.js +237 -70
  131. package/dist/lib/secrets/bundles.d.ts +26 -0
  132. package/dist/lib/secrets/bundles.js +59 -8
  133. package/dist/lib/secrets/filestore.d.ts +9 -0
  134. package/dist/lib/secrets/filestore.js +21 -8
  135. package/dist/lib/secrets/index.d.ts +7 -0
  136. package/dist/lib/secrets/index.js +26 -6
  137. package/dist/lib/secrets/mcp.js +4 -2
  138. package/dist/lib/secrets/remote.d.ts +17 -0
  139. package/dist/lib/secrets/remote.js +40 -0
  140. package/dist/lib/self-update.d.ts +20 -0
  141. package/dist/lib/self-update.js +54 -1
  142. package/dist/lib/serve/control.d.ts +95 -0
  143. package/dist/lib/serve/control.js +260 -0
  144. package/dist/lib/serve/server.d.ts +35 -1
  145. package/dist/lib/serve/server.js +106 -76
  146. package/dist/lib/serve/stream.d.ts +43 -0
  147. package/dist/lib/serve/stream.js +116 -0
  148. package/dist/lib/serve/token.d.ts +35 -0
  149. package/dist/lib/serve/token.js +85 -0
  150. package/dist/lib/session/bundle.d.ts +14 -0
  151. package/dist/lib/session/bundle.js +12 -1
  152. package/dist/lib/session/remote-list.js +5 -1
  153. package/dist/lib/session/state.d.ts +7 -25
  154. package/dist/lib/session/state.js +16 -6
  155. package/dist/lib/session/sync/config.js +8 -2
  156. package/dist/lib/session/types.d.ts +30 -0
  157. package/dist/lib/share/capture.d.ts +29 -0
  158. package/dist/lib/share/capture.js +140 -0
  159. package/dist/lib/share/config.d.ts +35 -0
  160. package/dist/lib/share/config.js +100 -0
  161. package/dist/lib/share/og.d.ts +25 -0
  162. package/dist/lib/share/og.js +84 -0
  163. package/dist/lib/share/provision.d.ts +10 -0
  164. package/dist/lib/share/provision.js +91 -0
  165. package/dist/lib/share/publish.d.ts +57 -0
  166. package/dist/lib/share/publish.js +145 -0
  167. package/dist/lib/share/worker-template.d.ts +2 -0
  168. package/dist/lib/share/worker-template.js +82 -0
  169. package/dist/lib/shims.d.ts +13 -0
  170. package/dist/lib/shims.js +42 -2
  171. package/dist/lib/ssh-exec.d.ts +24 -0
  172. package/dist/lib/ssh-exec.js +15 -3
  173. package/dist/lib/ssh-tunnel.d.ts +19 -1
  174. package/dist/lib/ssh-tunnel.js +86 -7
  175. package/dist/lib/startup/command-registry.d.ts +3 -0
  176. package/dist/lib/startup/command-registry.js +6 -0
  177. package/dist/lib/state.d.ts +5 -0
  178. package/dist/lib/state.js +12 -0
  179. package/dist/lib/tmux/session.d.ts +7 -0
  180. package/dist/lib/tmux/session.js +3 -1
  181. package/dist/lib/triggers/webhook.d.ts +18 -0
  182. package/dist/lib/triggers/webhook.js +105 -0
  183. package/dist/lib/types.d.ts +36 -1
  184. package/dist/lib/usage.js +7 -5
  185. package/dist/lib/versions.js +14 -11
  186. package/dist/lib/workflows.d.ts +20 -0
  187. package/dist/lib/workflows.js +24 -0
  188. package/package.json +2 -1
@@ -10,7 +10,7 @@ import { formatUsd, PRICING_VERSION } from '../lib/pricing/index.js';
10
10
  import { formatDuration } from '../lib/session/render.js';
11
11
  import { terminalWidth, truncateToWidth, padToWidth } from '../lib/session/width.js';
12
12
  import { collectGitOutput } from '../lib/output/git-output.js';
13
- import { loadDevices } from '../lib/devices/registry.js';
13
+ import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
14
14
  import { machineId } from '../lib/session/sync/config.js';
15
15
  const execFileAsync = promisify(execFile);
16
16
  export function registerOutputCommand(program) {
@@ -154,7 +154,7 @@ async function outputAction(options) {
154
154
  const self = machineId();
155
155
  const registry = await loadDevices();
156
156
  const remotes = Object.values(registry)
157
- .filter(d => d.tailscale?.online && d.name !== self)
157
+ .filter(d => d.tailscale?.online && d.name !== self && !isControlDevice(d))
158
158
  .map(d => d.name);
159
159
  if (!options.json)
160
160
  console.error(chalk.gray(`Folding in ${remotes.length} online device${remotes.length !== 1 ? 's' : ''}…`));
@@ -573,6 +573,7 @@ Examples:
573
573
  pluginsCmd
574
574
  .command('update [name]')
575
575
  .description('Re-pull a plugin from its original source and re-sync to all versions')
576
+ .option('--allow-exec-surfaces', 'Consent to an update that introduces new executable surfaces (hooks/, bin/, scripts/, .mcp.json, settings.json, permissions/)')
576
577
  .addHelpText('after', `
577
578
  Examples:
578
579
  # Update a specific plugin
@@ -580,8 +581,11 @@ Examples:
580
581
 
581
582
  # Update all plugins
582
583
  agents plugins update
584
+
585
+ # Trust an update that adds new executable surfaces
586
+ agents plugins update rush-toolkit --allow-exec-surfaces
583
587
  `)
584
- .action(async (nameArg) => {
588
+ .action(async (nameArg, options) => {
585
589
  const plugins = nameArg ? [getPlugin(nameArg)].filter(Boolean) : discoverPlugins();
586
590
  if (nameArg && plugins.length === 0) {
587
591
  console.log(chalk.red(`Plugin '${nameArg}' not found`));
@@ -591,24 +595,41 @@ Examples:
591
595
  console.log(chalk.gray('No plugins installed.'));
592
596
  return;
593
597
  }
598
+ const allowExec = options.allowExecSurfaces === true;
594
599
  for (const plugin of plugins) {
595
600
  process.stdout.write(`Updating ${plugin.name}... `);
596
- const result = await updatePlugin(plugin.name);
601
+ const result = await updatePlugin(plugin.name, { allowExecSurfaces: allowExec });
597
602
  if (!result.success) {
598
- console.log(chalk.red(`failed — ${result.error || 'unknown error'}`));
603
+ if (result.blockedByExecSurfaces) {
604
+ // Security (RUSH-1757): the update introduced new executable surfaces.
605
+ // Refuse without renewed consent; the last-good content stays in place.
606
+ console.log(chalk.yellow('skipped'));
607
+ console.log(chalk.yellow(` Update introduces new executable surfaces: ${(result.newExecSurfaces || []).join(', ')}`));
608
+ console.log(chalk.gray(' Kept the currently-installed revision. Re-run with --allow-exec-surfaces if you trust the source.'));
609
+ }
610
+ else {
611
+ console.log(chalk.red(`failed — ${result.error || 'unknown error'}`));
612
+ }
599
613
  continue;
600
614
  }
601
615
  console.log(chalk.green('done'));
602
- // Re-sync to all supported installed versions
616
+ // Reload the plugin so the re-sync reads the freshly-applied revision.
617
+ const updated = getPlugin(plugin.name) ?? plugin;
618
+ // Re-sync to all supported installed versions. When the applied revision
619
+ // carries executable surfaces, only enable them if the user consented on
620
+ // this update (--allow-exec-surfaces); otherwise the benign content syncs
621
+ // but stays disabled, matching the install-time trust gate.
603
622
  for (const agentId of capableAgents('plugins')) {
604
- if (!pluginSupportsAgent(plugin, agentId))
623
+ if (!pluginSupportsAgent(updated, agentId))
605
624
  continue;
606
625
  const versions = listInstalledVersions(agentId);
607
626
  const defaultVer = getGlobalDefault(agentId);
608
627
  const targetVersions = defaultVer ? [defaultVer] : versions.slice(-1);
609
628
  for (const version of targetVersions) {
610
- const syncResult = syncResourcesToVersion(agentId, version, { plugins: [plugin.name] });
611
- if (syncResult.plugins.length > 0) {
629
+ const didSync = allowExec
630
+ ? syncPluginToVersion(updated, agentId, getVersionHomePath(agentId, version), { allowExecSurfaces: true, version }).success
631
+ : syncResourcesToVersion(agentId, version, { plugins: [updated.name] }).plugins.length > 0;
632
+ if (didSync) {
612
633
  console.log(chalk.gray(` Re-synced to ${agentLabel(agentId)}@${version}`));
613
634
  }
614
635
  }
@@ -23,7 +23,7 @@ import { detectOverdueJobs } from '../lib/overdue.js';
23
23
  import { isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
24
24
  import { setHelpSections } from '../lib/help.js';
25
25
  import { loadDevices } from '../lib/devices/registry.js';
26
- import { normalizeHost } from '../lib/machine-id.js';
26
+ import { machineId, normalizeHost } from '../lib/machine-id.js';
27
27
  import { addHostOption } from '../lib/hosts/option.js';
28
28
  /**
29
29
  * Human-friendly wall-clock a run took (e.g. " · 3 min", " · 45 sec"), or ""
@@ -289,6 +289,7 @@ export function registerRoutinesCommands(program) {
289
289
  trigger: job.trigger ?? null,
290
290
  timezone: job.timezone ?? null,
291
291
  devices: job.devices ?? [],
292
+ host: job.host ?? null,
292
293
  runsHere: jobRunsOnThisDevice(job),
293
294
  enabled: job.enabled,
294
295
  overdue: overdueSet.has(job.name),
@@ -340,7 +341,10 @@ export function registerRoutinesCommands(program) {
340
341
  // chalk adds escape codes; pad the raw word and let chalk wrap it.
341
342
  const enabledWord = job.enabled ? 'yes' : 'no';
342
343
  const enabledPad = Math.max(0, ENABLED_W - enabledWord.length);
343
- const deviceFull = job.devices?.join(',') ?? '';
344
+ // Placement (`→host`) rides in the Devices cell: eligibility →execution.
345
+ const deviceFull = [job.devices?.join(',') ?? '', job.host ? `→${job.host}` : '']
346
+ .filter(Boolean)
347
+ .join(' ');
344
348
  const deviceWord = deviceFull.length === 0
345
349
  ? 'all'
346
350
  : deviceFull.length > DEVICE_W
@@ -384,6 +388,8 @@ export function registerRoutinesCommands(program) {
384
388
  .option('-t, --timeout <timeout>', 'Kill the agent if it runs longer than this (e.g., 10m, 2h, 3d, 1w; max 1w)', '10m')
385
389
  .option('--timezone <tz>', 'Interpret schedule in this timezone (e.g., America/Los_Angeles)')
386
390
  .option('--devices <names>', 'Fleet allowlist (comma-separated): only listed devices schedule and fire this routine. Omit for unrestricted.')
391
+ .option('--run-on <name>', 'Execute the job body on this machine over SSH (a registered host, device, capability tag, or user@host). Placement, not eligibility — see --devices. Auto-pins devices to THIS machine unless --devices is given.')
392
+ .option('--run-cwd <dir>', 'Working directory on the --run-on host (--remote-cwd is taken by the remote-management passthrough)')
387
393
  .option('--at <time>', 'One-shot mode: run once at this time (e.g., "14:30" or "2026-02-24 09:00"), then disable')
388
394
  .option('--on <source:event>', 'Webhook trigger instead of/in addition to a schedule: github:pull_request or linear:Issue')
389
395
  .option('--repo <owner/name>', 'GitHub repo filter for --on github:<event>')
@@ -448,6 +454,13 @@ export function registerRoutinesCommands(program) {
448
454
  if (options.devices !== undefined) {
449
455
  devices = await parseAndValidateDevices(options.devices);
450
456
  }
457
+ // --run-on without a --devices pin would fire on EVERY daemon in the
458
+ // fleet, each dispatching to the same host — duplicate runs. Pin to
459
+ // this machine unless the user chose an explicit eligibility set.
460
+ if (options.runOn && !devices) {
461
+ devices = [machineId()];
462
+ console.log(chalk.gray(`--run-on set with no --devices: pinned firing to this machine (${devices[0]}).`));
463
+ }
451
464
  const config = {
452
465
  name: nameOrPath,
453
466
  ...(schedule ? { schedule } : {}),
@@ -462,6 +475,8 @@ export function registerRoutinesCommands(program) {
462
475
  prompt: options.prompt ?? '',
463
476
  timezone: options.timezone,
464
477
  ...(devices ? { devices } : {}),
478
+ ...(options.runOn ? { host: options.runOn } : {}),
479
+ ...(options.runCwd ? { remoteCwd: options.runCwd } : {}),
465
480
  ...(runOnce ? { runOnce: true } : {}),
466
481
  ...(options.endAt ? { endAt: options.endAt } : {}),
467
482
  ...(options.resume ? { resume: options.resume } : {}),
@@ -520,6 +535,12 @@ export function registerRoutinesCommands(program) {
520
535
  enabled: true,
521
536
  ...parsed,
522
537
  };
538
+ // Same duplicate-fire guard as the --run-on flag: a host-placed routine
539
+ // with no eligibility pin would fire from every daemon in the fleet.
540
+ if (config.host && (!config.devices || config.devices.length === 0)) {
541
+ config.devices = [machineId()];
542
+ console.log(chalk.gray(`host: set with no devices pin: pinned firing to this machine (${config.devices[0]}).`));
543
+ }
523
544
  writeJob(config);
524
545
  console.log(chalk.green(`Job '${name}' added`));
525
546
  ensureSchedulerRunning();
@@ -66,11 +66,27 @@ export declare function buildSecretsExecEnv(parentEnv: NodeJS.ProcessEnv, secret
66
66
  * parse, so multi-line values are rejected rather than silently corrupted.
67
67
  */
68
68
  export declare function bundleEnvToDotenv(env: Record<string, string>): string;
69
+ /**
70
+ * Encrypt a resolved env map to an offline bundle file using AES-256-GCM
71
+ * (the same EncFile envelope as the per-item file store). Inner plaintext is
72
+ * JSON so multi-line values round-trip losslessly. Written with mode 0600;
73
+ * the passphrase must be supplied explicitly — never auto-provisioned.
74
+ */
75
+ export declare function exportBundleToFile(env: Record<string, string>, filePath: string, passphrase: string): void;
76
+ /**
77
+ * Decrypt and parse an offline bundle file produced by exportBundleToFile.
78
+ * Throws on a missing file, an invalid JSON envelope, or a wrong passphrase.
79
+ */
80
+ export declare function importBundleFromFile(filePath: string, passphrase: string): Record<string, string>;
69
81
  /** The POLICY column for `secrets list`: the prompt policy, plus a concise
70
82
  * state hint. `daily` shows `held Nh` when the secrets-agent is currently
71
83
  * caching the bundle; `always` and `never` show whether they prompt. `held`
72
84
  * maps bundle name → expiry epoch-ms (from agentStatus()). */
73
85
  export declare function renderPolicyCol(b: SecretsBundle, held?: Map<string, number>): string;
86
+ /** Human-readable hold window for `secrets status`. Sub-hour values render in
87
+ * minutes (so a near-floor `holdMs` never shows a confusing "0 hours"), whole
88
+ * hours up to 2 days, whole days beyond. Pure — unit-tested. */
89
+ export declare function formatHoldWindow(ms: number): string;
74
90
  /** Register the `agents secrets` command tree. */
75
91
  export declare function registerSecretsCommands(program: Command): void;
76
92
  /** Validate a prompt-policy value, throwing a clear message on a bad one (the
@@ -16,10 +16,13 @@ import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
16
16
  import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveSshTarget, } from '../lib/secrets/remote.js';
17
17
  import { remoteShellFor, buildWindowsStdinImportCommand } from '../lib/hosts/remote-cmd.js';
18
18
  import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
19
- import { bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
19
+ import { bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, isHeadlessSecretsContext, readBundle, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
20
+ import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
20
21
  import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
21
22
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
22
- import { DEFAULT_TTL_MS, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
23
+ import { secretsHoldMs, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
24
+ import { getCliVersionFresh } from '../lib/version.js';
25
+ import { readMeta } from '../lib/state.js';
23
26
  import { parseDuration } from '../lib/hooks/cache.js';
24
27
  import { emit } from '../lib/events.js';
25
28
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
@@ -298,6 +301,52 @@ export function bundleEnvToDotenv(env) {
298
301
  }
299
302
  return lines.join('\n') + '\n';
300
303
  }
304
+ /**
305
+ * Encrypt a resolved env map to an offline bundle file using AES-256-GCM
306
+ * (the same EncFile envelope as the per-item file store). Inner plaintext is
307
+ * JSON so multi-line values round-trip losslessly. Written with mode 0600;
308
+ * the passphrase must be supplied explicitly — never auto-provisioned.
309
+ */
310
+ export function exportBundleToFile(env, filePath, passphrase) {
311
+ const enc = encryptForFallback(JSON.stringify(env), passphrase);
312
+ fs.writeFileSync(filePath, JSON.stringify(enc), { mode: 0o600 });
313
+ }
314
+ /**
315
+ * Decrypt and parse an offline bundle file produced by exportBundleToFile.
316
+ * Throws on a missing file, an invalid JSON envelope, or a wrong passphrase.
317
+ */
318
+ export function importBundleFromFile(filePath, passphrase) {
319
+ const raw = fs.readFileSync(filePath, 'utf-8');
320
+ let enc;
321
+ try {
322
+ enc = JSON.parse(raw);
323
+ }
324
+ catch {
325
+ throw new Error(`Encrypted bundle file ${filePath} is corrupt (not valid JSON).`);
326
+ }
327
+ let plaintext;
328
+ try {
329
+ plaintext = decryptForFallback(enc, passphrase);
330
+ }
331
+ catch {
332
+ throw new Error(`Failed to decrypt bundle file ${filePath}. Wrong passphrase or tampered file.`);
333
+ }
334
+ let parsed;
335
+ try {
336
+ parsed = JSON.parse(plaintext);
337
+ }
338
+ catch {
339
+ throw new Error(`Decrypted bundle file ${filePath} has invalid content (expected JSON).`);
340
+ }
341
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
342
+ throw new Error(`Bundle file ${filePath} has unexpected structure.`);
343
+ }
344
+ const result = {};
345
+ for (const [k, v] of Object.entries(parsed)) {
346
+ result[k] = typeof v === 'string' ? v : String(v);
347
+ }
348
+ return result;
349
+ }
301
350
  /**
302
351
  * Browse `agents secrets <args>` on one or more remote hosts over SSH and print
303
352
  * each host's stdout verbatim (lossless — no parsing). With >1 host the output
@@ -400,6 +449,22 @@ export function renderPolicyCol(b, held) {
400
449
  const exp = held?.get(b.name);
401
450
  return exp ? chalk.green(`daily · held ${compactRemaining(exp)}`) : chalk.gray('daily');
402
451
  }
452
+ /** Human-readable hold window for `secrets status`. Sub-hour values render in
453
+ * minutes (so a near-floor `holdMs` never shows a confusing "0 hours"), whole
454
+ * hours up to 2 days, whole days beyond. Pure — unit-tested. */
455
+ export function formatHoldWindow(ms) {
456
+ if (ms < 3_600_000) { // under an hour → minutes (never a confusing "0 hours")
457
+ const mins = Math.max(1, Math.round(ms / 60_000));
458
+ if (mins < 60)
459
+ return `${mins} minute${mins === 1 ? '' : 's'}`;
460
+ // 59.99m rounds to 60 — call it 1 hour rather than "60 minutes".
461
+ }
462
+ const hrs = Math.round(ms / 3_600_000);
463
+ if (hrs < 48)
464
+ return `${hrs} hour${hrs === 1 ? '' : 's'}`;
465
+ const days = Math.round(hrs / 24);
466
+ return `${days} day${days === 1 ? '' : 's'}`;
467
+ }
403
468
  /** Below this width the fixed date columns no longer fit; `list` uses cards. */
404
469
  const SECRETS_WIDE = 96;
405
470
  /** Format a single bundle as a table row for the `secrets list` output. */
@@ -568,6 +633,51 @@ function countExpiringSoon(meta) {
568
633
  }
569
634
  return n;
570
635
  }
636
+ /**
637
+ * Resolve an existing import target bundle (inheriting its backend) or create a
638
+ * new one with the requested backend. Refuses to silently downgrade a
639
+ * keychain-backed bundle to `file` — shared by every `import` source so the
640
+ * guard can't drift between them.
641
+ */
642
+ function resolveImportBundle(name, backendOpt) {
643
+ const requestedBackend = parseBackendOpt(backendOpt);
644
+ if (bundleExists(name)) {
645
+ const bundle = readBundle(name);
646
+ if (requestedBackend === 'file' && bundle.backend !== 'file') {
647
+ throw new Error(`Bundle '${name}' already exists with a keychain backend; ` +
648
+ `--backend file cannot change it. Delete it first to recreate as file-backed.`);
649
+ }
650
+ return bundle;
651
+ }
652
+ return { name, backend: requestedBackend === 'file' ? 'file' : undefined, vars: {} };
653
+ }
654
+ /**
655
+ * Apply KEY=VALUE entries into a bundle (keychain item or plaintext literal),
656
+ * honoring `--force`, then persist. Returns the added/skipped tally. Shared by
657
+ * the .env, --from-file, and --from-ssh import paths.
658
+ */
659
+ function applyEnvToBundle(bundle, env, opts) {
660
+ const store = bundleItemStore(bundle.backend, { noAcl: bundlePolicy(bundle) === 'never' });
661
+ let added = 0;
662
+ let skipped = 0;
663
+ for (const [key, value] of Object.entries(env)) {
664
+ if (!opts.force && key in bundle.vars) {
665
+ skipped++;
666
+ continue;
667
+ }
668
+ if (opts.allPlaintext) {
669
+ bundle.vars[key] = { value };
670
+ }
671
+ else {
672
+ const item = secretsKeychainItem(bundle.name, key);
673
+ store.set(item, value);
674
+ bundle.vars[key] = keychainRef(key);
675
+ }
676
+ added++;
677
+ }
678
+ writeBundle(bundle);
679
+ return { added, skipped };
680
+ }
571
681
  /** Register the `agents secrets` command tree. */
572
682
  export function registerSecretsCommands(program) {
573
683
  const cmd = program
@@ -854,7 +964,10 @@ export function registerSecretsCommands(program) {
854
964
  console.error(chalk.red(`Secrets bundle '${item}' not found.`));
855
965
  process.exit(1);
856
966
  }
857
- const { env } = readAndResolveBundleEnv(item, { caller: 'secrets get', keys: [key] });
967
+ // `secrets get` is the scriptable automation primitive ($(agents secrets
968
+ // get bundle KEY)); when embedded in a headless routine/CI script it must
969
+ // not pop an unwatched Touch ID prompt. Interactive use still prompts.
970
+ const { env } = readAndResolveBundleEnv(item, { caller: 'secrets get', keys: [key], agentOnly: isHeadlessSecretsContext() });
858
971
  if (!(key in env)) {
859
972
  console.error(chalk.red(`Key '${key}' not in bundle '${item}'.`));
860
973
  process.exit(1);
@@ -1301,8 +1414,46 @@ Examples:
1301
1414
  .option('--backend <backend>', 'When creating the bundle: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
1302
1415
  .option('--force', 'Overwrite an existing key in the bundle')
1303
1416
  .option('--purge', 'With --from icloud: delete the iCloud copies after a successful import (iCloud propagates the deletion to your other devices)')
1417
+ .option('--from-file <path>', 'Import from an AES-256-GCM encrypted offline bundle file (needs AGENTS_SECRETS_PASSPHRASE; symmetric counterpart of export --to-file)')
1418
+ .option('--from-ssh', 'Pull the bundle from a fleet peer over SSH and import it locally (requires --host)')
1419
+ .option('--host <peer>', 'SSH peer to pull from when using --from-ssh (host alias or user@host)')
1304
1420
  .action(async (bundleName, opts) => {
1305
1421
  try {
1422
+ // A single import can name only one source. --from-file / --from-ssh are
1423
+ // early-return paths, so guard them against each other and the --from /
1424
+ // --from-1password pair (which parseImportSource guards on its own).
1425
+ const namedFileOrSsh = [
1426
+ opts.fromFile ? '--from-file' : null,
1427
+ opts.fromSsh ? '--from-ssh' : null,
1428
+ ].filter(Boolean);
1429
+ if (namedFileOrSsh.length > 1 || (namedFileOrSsh.length > 0 && (opts.from || opts.from1password))) {
1430
+ throw new Error('--from-file, --from-ssh, and --from/--from-1password are mutually exclusive; pick one import source.');
1431
+ }
1432
+ if (opts.fromFile) {
1433
+ const passphrase = process.env.AGENTS_SECRETS_PASSPHRASE ?? '';
1434
+ if (!passphrase) {
1435
+ throw new Error('--from-file needs AGENTS_SECRETS_PASSPHRASE set to decrypt the bundle file.');
1436
+ }
1437
+ const env = importBundleFromFile(opts.fromFile, passphrase);
1438
+ const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
1439
+ const bundle = resolveImportBundle(resolvedBundleName, opts.backend);
1440
+ const { added, skipped } = applyEnvToBundle(bundle, env, opts);
1441
+ console.log(chalk.green(`Imported ${added} key(s) from file${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
1442
+ return;
1443
+ }
1444
+ if (opts.fromSsh) {
1445
+ if (!opts.host) {
1446
+ throw new Error('--from-ssh requires --host <peer>.');
1447
+ }
1448
+ assertValidSshTarget(opts.host);
1449
+ const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
1450
+ const target = await resolveSshTarget(opts.host);
1451
+ const env = await remoteResolveEnv(target, resolvedBundleName, { osLookupName: opts.host });
1452
+ const bundle = resolveImportBundle(resolvedBundleName, opts.backend);
1453
+ const { added, skipped } = applyEnvToBundle(bundle, env, opts);
1454
+ console.log(chalk.green(`Imported ${added} key(s) from ${opts.host}${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
1455
+ return;
1456
+ }
1306
1457
  const source = parseImportSource(opts);
1307
1458
  if (opts.purge && source.kind !== 'icloud') {
1308
1459
  throw new Error('--purge only applies to --from icloud.');
@@ -1321,49 +1472,20 @@ Examples:
1321
1472
  return;
1322
1473
  }
1323
1474
  const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
1324
- // Read the bundle if it exists (inheriting its backend); otherwise
1325
- // create it with the requested backend so a single `import --backend
1326
- // file` works (this is what `export --host ... --remote-backend file`
1327
- // drives on the remote).
1328
- let bundle;
1329
- if (bundleExists(resolvedBundleName)) {
1330
- bundle = readBundle(resolvedBundleName);
1331
- if (requestedBackend === 'file' && bundle.backend !== 'file') {
1332
- throw new Error(`Bundle '${resolvedBundleName}' already exists with a keychain backend; ` +
1333
- `--backend file cannot change it. Delete it first to recreate as file-backed.`);
1334
- }
1335
- }
1336
- else {
1337
- bundle = {
1338
- name: resolvedBundleName,
1339
- backend: requestedBackend === 'file' ? 'file' : undefined,
1340
- vars: {},
1341
- };
1342
- }
1343
- const store = bundleItemStore(bundle.backend, { noAcl: bundlePolicy(bundle) === 'never' });
1344
- let added = 0;
1345
- let skipped = 0;
1475
+ // resolveImportBundle inherits an existing bundle's backend (and refuses
1476
+ // to downgrade keychain -> file) or creates it with the requested backend
1477
+ // so a single `import --backend file` works (what `export --host ...
1478
+ // --remote-backend file` drives on the remote).
1479
+ const bundle = resolveImportBundle(resolvedBundleName, opts.backend);
1346
1480
  if (source.kind === '1password') {
1347
1481
  assertOpAvailable();
1348
1482
  const vault = await resolveVault(source.vault);
1349
1483
  const items = listItems(vault);
1350
1484
  const { secrets, skipped: opSkipped } = extractSecrets(items, vault);
1351
- for (const { envKey, value } of secrets) {
1352
- if (!opts.force && envKey in bundle.vars) {
1353
- skipped++;
1354
- continue;
1355
- }
1356
- if (opts.allPlaintext) {
1357
- bundle.vars[envKey] = { value };
1358
- }
1359
- else {
1360
- const item = secretsKeychainItem(resolvedBundleName, envKey);
1361
- store.set(item, value);
1362
- bundle.vars[envKey] = keychainRef(envKey);
1363
- }
1364
- added++;
1365
- }
1366
- writeBundle(bundle);
1485
+ const env = {};
1486
+ for (const { envKey, value } of secrets)
1487
+ env[envKey] = value;
1488
+ const { added, skipped } = applyEnvToBundle(bundle, env, opts);
1367
1489
  if (opSkipped.length) {
1368
1490
  console.log(chalk.yellow(`Skipped ${opSkipped.length} item(s) with no importable fields.`));
1369
1491
  }
@@ -1372,22 +1494,7 @@ Examples:
1372
1494
  else {
1373
1495
  const raw = readImportDotenv(source.path);
1374
1496
  const pairs = parseDotenv(raw);
1375
- for (const [key, value] of Object.entries(pairs)) {
1376
- if (!opts.force && key in bundle.vars) {
1377
- skipped++;
1378
- continue;
1379
- }
1380
- if (opts.allPlaintext) {
1381
- bundle.vars[key] = { value };
1382
- }
1383
- else {
1384
- const item = secretsKeychainItem(resolvedBundleName, key);
1385
- store.set(item, value);
1386
- bundle.vars[key] = keychainRef(key);
1387
- }
1388
- added++;
1389
- }
1390
- writeBundle(bundle);
1497
+ const { added, skipped } = applyEnvToBundle(bundle, pairs, opts);
1391
1498
  console.log(chalk.green(`Imported ${added} key(s)${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
1392
1499
  }
1393
1500
  }
@@ -1408,10 +1515,22 @@ Examples:
1408
1515
  .option('--remote-backend <backend>', 'Backend for the bundle on the remote (with --host): keychain (default) or file (passphrase-encrypted, headless-readable). file forwards AGENTS_SECRETS_PASSPHRASE over stdin.', 'keychain')
1409
1516
  .option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --host)')
1410
1517
  .option('--format <shell|json>', 'Output for --plaintext export: shell (default) or json (lossless, machine-readable; used by remote resolve)', 'shell')
1518
+ .option('--to-file <path>', 'Write the bundle as an AES-256-GCM encrypted offline file (needs AGENTS_SECRETS_PASSPHRASE; symmetric counterpart of import --from-file)')
1411
1519
  .action(async (bundleName, opts) => {
1412
1520
  try {
1413
1521
  const { readAndResolveBundleEnv, bundleToEnvPrefix, isReservedEnvName } = await import('../lib/secrets/bundles.js');
1414
1522
  const resolvedBundleName = bundleName ?? (await pickBundleName('export'));
1523
+ if (opts.toFile) {
1524
+ const passphrase = process.env.AGENTS_SECRETS_PASSPHRASE ?? '';
1525
+ if (!passphrase) {
1526
+ throw new Error('--to-file needs AGENTS_SECRETS_PASSPHRASE set to encrypt the bundle. ' +
1527
+ 'Set it for this command, then supply the same value when importing.');
1528
+ }
1529
+ const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: 'export --to-file', agentOnly: isHeadlessSecretsContext() });
1530
+ exportBundleToFile(env, opts.toFile, passphrase);
1531
+ console.log(chalk.green(`Exported ${Object.keys(env).length} key(s) to ${opts.toFile}`));
1532
+ return;
1533
+ }
1415
1534
  // The presence of --host selects SSH push: --host is the destination
1416
1535
  // and carries the mode (no separate --to-ssh needed — it would be
1417
1536
  // strictly redundant since SSH always requires at least one host).
@@ -1434,7 +1553,7 @@ Examples:
1434
1553
  'bundle at rest on the remote. Set it for this command, then unlock it the same way per run.');
1435
1554
  }
1436
1555
  }
1437
- const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export` });
1556
+ const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export`, agentOnly: isHeadlessSecretsContext() });
1438
1557
  const dotenv = bundleEnvToDotenv(env);
1439
1558
  const keyCount = Object.keys(env).length;
1440
1559
  // Drive the remote's own `agents secrets import --from -` so the values
@@ -1499,7 +1618,7 @@ Examples:
1499
1618
  if (opts.to1password) {
1500
1619
  assertOpAvailable();
1501
1620
  const vault = await resolveVault(opts.vault);
1502
- const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `1Password vault ${vault}` });
1621
+ const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `1Password vault ${vault}`, agentOnly: isHeadlessSecretsContext() });
1503
1622
  let created = 0;
1504
1623
  let overwritten = 0;
1505
1624
  let skipped = 0;
@@ -1537,7 +1656,15 @@ Examples:
1537
1656
  console.error(chalk.red('export prints secrets in the clear and requires --plaintext (works for TTY and pipes alike).'));
1538
1657
  process.exit(1);
1539
1658
  }
1540
- const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `export to shell` });
1659
+ // `agents secrets export --plaintext` is what release/CI scripts eval.
1660
+ // When it runs detached (both stdio non-TTY) or under a headless agent,
1661
+ // resolve broker-only so it can never pop a Touch ID sheet on the
1662
+ // interactive user's screen. An interactive `eval "$(...)"` keeps its
1663
+ // terminal stdin, so it is not headless and still prompts.
1664
+ const { env } = readAndResolveBundleEnv(resolvedBundleName, {
1665
+ caller: `export to shell`,
1666
+ agentOnly: isHeadlessSecretsContext(),
1667
+ });
1541
1668
  if (opts.format === 'json') {
1542
1669
  // Lossless, machine-readable form consumed by `remoteResolveEnv` over
1543
1670
  // SSH. Single object of KEY -> value; values verbatim (newlines, quotes).
@@ -1592,6 +1719,7 @@ Examples:
1592
1719
  caller: `command ${cmd}`,
1593
1720
  keys: keysSubset,
1594
1721
  allowExpired: execOpts.allowExpired,
1722
+ agentOnly: isHeadlessSecretsContext(),
1595
1723
  }).env;
1596
1724
  }
1597
1725
  const { spawn } = await import('child_process');
@@ -1760,7 +1888,7 @@ Examples:
1760
1888
  console.error(chalk.red('Specify one or more bundle names, or --all.'));
1761
1889
  process.exit(1);
1762
1890
  }
1763
- let ttlMs = DEFAULT_TTL_MS;
1891
+ let ttlMs = secretsHoldMs(); // default hold, capped by secrets.agent.holdMs
1764
1892
  if (opts.ttl) {
1765
1893
  const secs = parseDuration(opts.ttl);
1766
1894
  if (!secs) {
@@ -1830,21 +1958,44 @@ Examples:
1830
1958
  console.log(chalk.gray('secrets-agent is macOS-only.'));
1831
1959
  return;
1832
1960
  }
1833
- const brokerUp = (await agentPing()).reachable;
1961
+ const ping = await agentPing();
1962
+ const brokerUp = ping.reachable;
1834
1963
  console.log(chalk.gray('broker: ') +
1835
1964
  (brokerUp
1836
1965
  ? chalk.green('running') + chalk.gray(isDaemonRunning() ? ' (hosted by the daemon)' : ' (standalone)')
1837
1966
  : chalk.yellow('not running — starts on demand, or run `agents secrets start` to bring the daemon up now')));
1967
+ // Diagnostic: version skew is the top reason a `daily` bundle keeps
1968
+ // re-prompting — a broker on an older build gets torn down when the CLI
1969
+ // version changes (e.g. `agents-cli-update`), wiping every held bundle.
1970
+ const onDisk = getCliVersionFresh();
1971
+ if (brokerUp && ping.cliVersion && ping.cliVersion !== onDisk) {
1972
+ console.log(chalk.yellow(` warning: broker is running an older build (${ping.cliVersion} vs ${onDisk} on disk). ` +
1973
+ `A version change can wipe held bundles — reads re-warm on the next access.`));
1974
+ }
1975
+ // Surface the hold window so "why did it prompt again" is answerable.
1976
+ const holdStr = formatHoldWindow(secretsHoldMs());
1977
+ // Only claim "(secrets.agent.holdMs)" when the config is actually honored —
1978
+ // an invalid value (0/NaN/negative) falls back to the default via
1979
+ // clampHoldMs, so it must read "(default)", not misattribute to config.
1980
+ const configured = (() => { try {
1981
+ const v = readMeta().secrets?.agent?.holdMs;
1982
+ return typeof v === 'number' && Number.isFinite(v) && v > 0;
1983
+ }
1984
+ catch {
1985
+ return false;
1986
+ } })();
1987
+ console.log(chalk.gray(`hold: ${holdStr}${configured ? ' (secrets.agent.holdMs)' : ' (default)'} — a daily bundle prompts once, then stays silent for this long or until sleep/logout.`));
1838
1988
  const entries = await agentStatus();
1839
1989
  if (entries.length === 0) {
1840
- console.log(chalk.gray('No bundles unlocked. The secrets broker is idle or not running.'));
1841
- console.log(chalk.gray('Try: agents secrets unlock <bundle>'));
1990
+ console.log(chalk.gray('No bundles held. The next read of each daily bundle will prompt once, then hold.'));
1991
+ console.log(chalk.gray('Pre-warm now with: agents secrets unlock <bundle> (or --all)'));
1842
1992
  return;
1843
1993
  }
1844
1994
  console.log(chalk.bold(`${'BUNDLE'.padEnd(24)} ${'KEYS'.padEnd(5)} LOCKS IN`));
1845
1995
  for (const e of entries) {
1846
1996
  console.log(`${chalk.cyan(e.name.padEnd(24))} ${String(e.keyCount).padEnd(5)} ${humanRemaining(e.expiresAt)}`);
1847
1997
  }
1998
+ console.log(chalk.gray('Reads of held bundles are silent; any bundle not listed prompts once on its next read.'));
1848
1999
  });
1849
2000
  cmd
1850
2001
  .command('policy <bundle> [policy]')
@@ -1,15 +1,46 @@
1
1
  import chalk from 'chalk';
2
2
  import { startServeServer, DEFAULT_SERVE_PORT, SERVE_HOST } from '../lib/serve/server.js';
3
+ import { startControlServer } from '../lib/serve/control.js';
4
+ import { ensureControlToken } from '../lib/serve/token.js';
3
5
  export function registerServeCommand(program) {
4
6
  program
5
7
  .command('serve')
6
8
  .description('Read-only local web companion: team diffs, routines, and cloud status (binds 127.0.0.1 only).')
7
9
  .option('-p, --port <n>', `Port to bind on ${SERVE_HOST}`, String(DEFAULT_SERVE_PORT))
8
10
  .option('--interval <ms>', 'SSE refresh cadence in milliseconds', '3000')
11
+ .option('--control', 'Authenticated control mode (RUSH-1731): adds bearer-gated POST /api/run and /api/session/:id/message so the iOS cockpit can dispatch + steer. Enables binding beyond loopback — pair a device with `agents devices pair-ios`.', false)
12
+ .option('--bind <addr>', 'Address to bind in --control mode (e.g. your tailnet IP, or 0.0.0.0). Ignored without --control; read-only serve is always loopback.', SERVE_HOST)
9
13
  .action(async (opts) => {
10
14
  const port = parseInt(opts.port ?? '', 10) || DEFAULT_SERVE_PORT;
11
15
  const intervalMs = parseInt(opts.interval ?? '', 10) || 3000;
12
16
  try {
17
+ if (opts.control) {
18
+ const bind = opts.bind || SERVE_HOST;
19
+ const minted = ensureControlToken('default');
20
+ const { server, port: bound } = await startControlServer(port, bind, {
21
+ cwd: process.cwd(),
22
+ intervalMs,
23
+ });
24
+ const url = `http://${bind}:${bound}`;
25
+ console.log(`${chalk.green('agents serve --control')} ${chalk.dim('→')} ${chalk.cyan(url)}`);
26
+ if (minted.created) {
27
+ console.log(chalk.yellow('New control token (shown once — store it in the cockpit now):'));
28
+ console.log(` ${chalk.bold(minted.token)} ${chalk.dim(`(id ${minted.id})`)}`);
29
+ }
30
+ else {
31
+ console.log(chalk.dim('Using existing control token(s). Issue another with `agents devices pair-ios`.'));
32
+ }
33
+ if (bind !== SERVE_HOST) {
34
+ console.log(chalk.dim('bearer-gated · bind beyond loopback — keep this on the tailnet, never public Funnel · Ctrl-C to stop'));
35
+ }
36
+ else {
37
+ console.log(chalk.dim('bearer-gated · loopback (pass --bind <tailnet-ip> to reach it from the phone) · Ctrl-C to stop'));
38
+ }
39
+ const shutdown = () => server.close(() => process.exit(0));
40
+ process.on('SIGINT', shutdown);
41
+ process.on('SIGTERM', shutdown);
42
+ return;
43
+ }
13
44
  const { server, port: bound } = await startServeServer(port, {
14
45
  cwd: process.cwd(),
15
46
  intervalMs,