@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
@@ -134,12 +134,34 @@ export function pluginResourceGroups(plugin) {
134
134
  out.push({ label: 'settings', items: ['settings.json'] });
135
135
  return out;
136
136
  }
137
+ /**
138
+ * True when a manifest field declares an inline execution surface — a non-empty
139
+ * path string, a non-empty array, or an object with at least one key. The
140
+ * official plugin format lets `hooks`/`mcpServers` live inline in the manifest
141
+ * (a path or an inline map) instead of as a `hooks/` dir or `.mcp.json` file, so
142
+ * filesystem-only detection would miss them and auto-enable a hostile plugin.
143
+ */
144
+ function manifestDeclaresExecSurface(value) {
145
+ if (typeof value === 'string')
146
+ return value.trim().length > 0;
147
+ if (Array.isArray(value))
148
+ return value.length > 0;
149
+ if (value && typeof value === 'object')
150
+ return Object.keys(value).length > 0;
151
+ return false;
152
+ }
137
153
  export function inspectPluginCapabilities(pluginRoot) {
138
154
  const manifest = loadPluginManifest(pluginRoot);
139
155
  const plugin = manifest ? buildDiscoveredPlugin(pluginRoot, manifest) : null;
140
156
  return {
141
- hasHooks: (plugin?.hooks.length || 0) > 0 || pluginHasDirectoryEntries(pluginRoot, 'hooks'),
142
- hasMcp: fs.existsSync(path.join(pluginRoot, '.mcp.json')),
157
+ // Inline manifest `hooks`/`mcpServers` are execution surfaces too a cloned
158
+ // repo's project plugin must not be auto-enabled just because it ships the
159
+ // exec config inline in plugin.json rather than as a hooks/ dir or .mcp.json.
160
+ hasHooks: (plugin?.hooks.length || 0) > 0 ||
161
+ pluginHasDirectoryEntries(pluginRoot, 'hooks') ||
162
+ manifestDeclaresExecSurface(manifest?.hooks),
163
+ hasMcp: fs.existsSync(path.join(pluginRoot, '.mcp.json')) ||
164
+ manifestDeclaresExecSurface(manifest?.mcpServers),
143
165
  hasBin: (plugin?.bin.length || 0) > 0,
144
166
  hasScripts: (plugin?.scripts.length || 0) > 0,
145
167
  hasSettings: pluginHasNonPermissionSettings(pluginRoot),
@@ -499,6 +521,14 @@ export function syncPluginToVersion(plugin, agent, versionHome, options = {}) {
499
521
  // OpenCode uses TS/JS modules under ~/.config/opencode/plugins/, not the
500
522
  // Claude marketplace layout. Install those modules and return early.
501
523
  if (agent === 'opencode') {
524
+ // Trust gate (RUSH-1756): OpenCode plugins are raw executable TS/JS modules,
525
+ // so they must clear the same consent check as every other exec surface
526
+ // before install — this branch used to return early, bypassing the gate the
527
+ // Gemini/Hermes/marketplace branches all apply.
528
+ const enablePlugin = options.allowExecSurfaces === true || !hasPluginExecSurfaces(inspectPluginCapabilities(plugin.root));
529
+ if (!enablePlugin) {
530
+ return result;
531
+ }
502
532
  const ok = installOpenCodePlugin(plugin, versionHome);
503
533
  result.success = ok;
504
534
  if (ok)
@@ -982,6 +1012,72 @@ function writeGeminiExtensionManifest(plugin, destRoot) {
982
1012
  }
983
1013
  fs.writeFileSync(path.join(destRoot, GEMINI_EXTENSION_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, 'utf-8');
984
1014
  }
1015
+ /**
1016
+ * Security (RUSH-1755): `fs.cpSync(..., { recursive: true })` copies symlinks
1017
+ * verbatim (dereference defaults to false), so a malicious plugin can ship a
1018
+ * symlink whose target escapes the install root — e.g.
1019
+ * `.agents-cli-managed -> ~/.bashrc`. The managed-marker / manifest writes that
1020
+ * follow the copy would then write THROUGH the link, clobbering an
1021
+ * attacker-chosen path outside the install root.
1022
+ *
1023
+ * Walk destRoot after the recursive copy, lstat each entry, and remove any
1024
+ * symlink whose resolved target escapes BOTH destRoot and sourceRoot. Both roots
1025
+ * matter because Node's cpSync rewrites a *relative* internal symlink
1026
+ * (`./x`) into an *absolute* link back into the source tree, so a legitimate
1027
+ * internal symlink resolves under sourceRoot (not destRoot) after the copy.
1028
+ * Keeping targets within sourceRoot preserves those internal symlinks — matching
1029
+ * copyPluginToMarketplace's policy — while genuinely external escapes are
1030
+ * dropped, neutralizing the write-through.
1031
+ */
1032
+ function stripEscapingSymlinks(destRoot, sourceRoot) {
1033
+ const realRoots = [destRoot, sourceRoot].map((r) => {
1034
+ try {
1035
+ return fs.realpathSync(r);
1036
+ }
1037
+ catch {
1038
+ return r;
1039
+ }
1040
+ });
1041
+ const within = (target) => realRoots.some((root) => target === root || target.startsWith(root + path.sep));
1042
+ const removed = [];
1043
+ const walk = (dir) => {
1044
+ let entries;
1045
+ try {
1046
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1047
+ }
1048
+ catch {
1049
+ return;
1050
+ }
1051
+ for (const entry of entries) {
1052
+ const full = path.join(dir, entry.name);
1053
+ if (entry.isSymbolicLink()) {
1054
+ let escapes;
1055
+ try {
1056
+ escapes = !within(fs.realpathSync(full));
1057
+ }
1058
+ catch {
1059
+ // Dangling / unresolvable symlink — treat as escaping and drop it.
1060
+ escapes = true;
1061
+ }
1062
+ if (escapes) {
1063
+ try {
1064
+ fs.rmSync(full, { force: true });
1065
+ removed.push(path.relative(destRoot, full) || entry.name);
1066
+ }
1067
+ catch { /* best effort */ }
1068
+ }
1069
+ }
1070
+ else if (entry.isDirectory()) {
1071
+ // Do not descend into symlinked dirs — isDirectory() is false for a
1072
+ // symlink even when it points at a directory, so this only recurses
1073
+ // into real subdirectories, keeping the walk inside destRoot.
1074
+ walk(full);
1075
+ }
1076
+ }
1077
+ };
1078
+ walk(destRoot);
1079
+ return removed;
1080
+ }
985
1081
  export function installGeminiPlugin(plugin, versionHome) {
986
1082
  const destRoot = path.join(geminiExtensionsDir(versionHome), plugin.name);
987
1083
  try {
@@ -989,6 +1085,7 @@ export function installGeminiPlugin(plugin, versionHome) {
989
1085
  fs.rmSync(destRoot, { recursive: true, force: true });
990
1086
  }
991
1087
  fs.cpSync(plugin.root, destRoot, { recursive: true });
1088
+ stripEscapingSymlinks(destRoot, plugin.root);
992
1089
  const userConfig = loadUserConfig(plugin.name);
993
1090
  if (Object.keys(userConfig).length > 0) {
994
1091
  expandUserConfigInDir(destRoot, userConfig);
@@ -1030,6 +1127,7 @@ export function installGoosePlugin(plugin, versionHome) {
1030
1127
  fs.rmSync(destRoot, { recursive: true, force: true });
1031
1128
  }
1032
1129
  fs.cpSync(plugin.root, destRoot, { recursive: true });
1130
+ stripEscapingSymlinks(destRoot, plugin.root);
1033
1131
  fs.writeFileSync(path.join(destRoot, '.agents-cli-managed'), `plugin=${plugin.name}\n`, 'utf-8');
1034
1132
  return true;
1035
1133
  }
@@ -1111,6 +1209,7 @@ export function installHermesPlugin(plugin, versionHome, enable) {
1111
1209
  fs.rmSync(destRoot, { recursive: true, force: true });
1112
1210
  }
1113
1211
  fs.cpSync(plugin.root, destRoot, { recursive: true });
1212
+ stripEscapingSymlinks(destRoot, plugin.root);
1114
1213
  const userConfig = loadUserConfig(plugin.name);
1115
1214
  if (Object.keys(userConfig).length > 0) {
1116
1215
  expandUserConfigInDir(destRoot, userConfig);
@@ -1640,7 +1739,33 @@ export function getUpstreamManifestVersion(info) {
1640
1739
  * Update an installed plugin by re-pulling from its original source.
1641
1740
  * Returns true if the update succeeded.
1642
1741
  */
1643
- export async function updatePlugin(name) {
1742
+ /**
1743
+ * Labels of exec surfaces present in `after` that were NOT present in `before`.
1744
+ * Used by updatePlugin to distinguish a newly-appearing execution surface
1745
+ * (upstream compromise → renewed consent required) from one the user already
1746
+ * trusted (leave enablement alone).
1747
+ */
1748
+ export function newExecSurfaceLabels(before, after) {
1749
+ return Object.keys(PLUGIN_EXEC_SURFACE_LABELS)
1750
+ .filter((key) => after[key] && !before[key])
1751
+ .map((key) => PLUGIN_EXEC_SURFACE_LABELS[key]);
1752
+ }
1753
+ /**
1754
+ * Re-fetch a plugin from its recorded source and apply the update to disk.
1755
+ *
1756
+ * Security (RUSH-1757): a plugin's upstream is mutable. `updatePlugin` never
1757
+ * mutates the live plugin tree before it has inspected the incoming content —
1758
+ * the new revision is fetched into a **quarantine** dir first, its capabilities
1759
+ * are diffed against the current on-disk baseline, and the update is applied to
1760
+ * `plugin.root` only after the trust decision. If the update introduces a NEW
1761
+ * executable surface (hooks/, .mcp.json, bin/, scripts/, settings.json,
1762
+ * permissions/) that the current revision did not carry, the update is refused
1763
+ * unless `options.allowExecSurfaces` is set — the last-good content is kept in
1764
+ * place, so a benign-then-compromised upstream can never execute on the next
1765
+ * update without renewed consent. A surface the user already trusted is not a
1766
+ * "new" surface and does not re-trigger the gate.
1767
+ */
1768
+ export async function updatePlugin(name, options = {}) {
1644
1769
  const plugin = getPlugin(name);
1645
1770
  if (!plugin) {
1646
1771
  return { success: false, error: `Plugin '${name}' not found` };
@@ -1656,33 +1781,68 @@ export async function updatePlugin(name) {
1656
1781
  catch {
1657
1782
  return { success: false, error: `Could not read source info for '${name}'` };
1658
1783
  }
1784
+ // Baseline: what the live (already-trusted) revision ships today.
1785
+ const before = inspectPluginCapabilities(plugin.root);
1786
+ // Quarantine dir lives beside plugin.root (same filesystem) so the final
1787
+ // apply can be a rename. The dot-prefix keeps it out of plugin discovery.
1788
+ const quarantine = path.join(path.dirname(plugin.root), `.${path.basename(plugin.root)}.update-quarantine`);
1789
+ const cleanupQuarantine = () => {
1790
+ try {
1791
+ fs.rmSync(quarantine, { recursive: true, force: true });
1792
+ }
1793
+ catch { /* best effort */ }
1794
+ };
1795
+ cleanupQuarantine();
1659
1796
  try {
1797
+ // 1. Fetch the incoming revision into quarantine — never touch plugin.root yet.
1660
1798
  if (sourceInfo.isGit) {
1661
- execFileSync('git', ['-C', plugin.root, 'pull', '--ff-only'], { stdio: 'pipe' });
1799
+ // Copy the working checkout (with its .git) and fast-forward the copy, so a
1800
+ // hostile upstream diff lands in the quarantine, not the live tree.
1801
+ fs.cpSync(plugin.root, quarantine, { recursive: true });
1802
+ execFileSync('git', ['-C', quarantine, 'pull', '--ff-only'], { stdio: 'pipe' });
1662
1803
  }
1663
1804
  else {
1664
1805
  const resolvedSource = sourceInfo.source.replace(/^~/, homeDir());
1665
1806
  if (!fs.existsSync(resolvedSource)) {
1807
+ cleanupQuarantine();
1666
1808
  return { success: false, error: `Source path no longer exists: ${resolvedSource}` };
1667
1809
  }
1668
- // Preserve .user-config.json and .source during re-copy
1669
- const userConfigPath = path.join(plugin.root, USER_CONFIG_FILE);
1670
- const userConfigBackup = fs.existsSync(userConfigPath)
1671
- ? fs.readFileSync(userConfigPath, 'utf-8')
1672
- : null;
1673
- fs.rmSync(plugin.root, { recursive: true, force: true });
1674
- fs.cpSync(resolvedSource, plugin.root, { recursive: true });
1675
- if (userConfigBackup !== null) {
1676
- fs.writeFileSync(userConfigPath, userConfigBackup, 'utf-8');
1677
- }
1810
+ fs.cpSync(resolvedSource, quarantine, { recursive: true });
1811
+ }
1812
+ // 2. Diff capabilities of the incoming revision against the baseline.
1813
+ const after = inspectPluginCapabilities(quarantine);
1814
+ const newSurfaces = newExecSurfaceLabels(before, after);
1815
+ // 3. Refuse a surface-introducing update without renewed consent. The
1816
+ // last-good content stays in place untouched.
1817
+ if (newSurfaces.length > 0 && options.allowExecSurfaces !== true) {
1818
+ cleanupQuarantine();
1819
+ return {
1820
+ success: false,
1821
+ blockedByExecSurfaces: true,
1822
+ newExecSurfaces: newSurfaces,
1823
+ error: `Update refused: '${name}' introduces new executable surfaces (${newSurfaces.join(', ')}). ` +
1824
+ `Re-run with --allow-exec-surfaces if you trust the source.`,
1825
+ };
1826
+ }
1827
+ // 4. Apply: swap the quarantined revision into plugin.root, preserving the
1828
+ // user config and re-stamping .source.
1829
+ const userConfigPath = path.join(plugin.root, USER_CONFIG_FILE);
1830
+ const userConfigBackup = fs.existsSync(userConfigPath)
1831
+ ? fs.readFileSync(userConfigPath, 'utf-8')
1832
+ : null;
1833
+ fs.rmSync(plugin.root, { recursive: true, force: true });
1834
+ fs.renameSync(quarantine, plugin.root);
1835
+ if (userConfigBackup !== null) {
1836
+ fs.writeFileSync(userConfigPath, userConfigBackup, 'utf-8');
1678
1837
  }
1679
1838
  // Re-stamp .source with the freshly pulled manifest version so the baseline
1680
1839
  // tracks what's now on disk (keeps the heal "unmodified?" check honest).
1681
1840
  const freshVersion = loadPluginManifest(plugin.root)?.version;
1682
1841
  fs.writeFileSync(path.join(plugin.root, SOURCE_FILE), JSON.stringify({ ...sourceInfo, version: freshVersion }), 'utf-8');
1842
+ return { success: true, newExecSurfaces: newSurfaces, hasExecSurfaces: hasPluginExecSurfaces(after) };
1683
1843
  }
1684
1844
  catch (err) {
1845
+ cleanupQuarantine();
1685
1846
  return { success: false, error: err.message };
1686
1847
  }
1687
- return { success: true };
1688
1848
  }
@@ -1,4 +1,17 @@
1
1
  /**
2
2
  * Shared redaction helpers for text that may be exported or logged.
3
3
  */
4
- export declare function redactSecrets(text: string): string;
4
+ /**
5
+ * Scrub secrets from `text`. Two passes: format-based patterns (above), then a
6
+ * value-aware pass that masks any `knownValues` verbatim — a credential we
7
+ * already hold in hand leaks regardless of its format, so an exact-value match
8
+ * catches tokens the regexes don't recognize.
9
+ */
10
+ export declare function redactSecrets(text: string, knownValues?: readonly string[]): string;
11
+ /**
12
+ * Secret values already present in the environment (e.g. an injected secrets
13
+ * bundle), selected by secret-shaped var NAME. These are the "known" values fed
14
+ * to {@link redactSecrets} so an exported transcript can't leak a live
15
+ * credential verbatim even when its format matches no pattern.
16
+ */
17
+ export declare function knownSecretValuesFromEnv(env?: NodeJS.ProcessEnv): string[];
@@ -3,17 +3,63 @@
3
3
  */
4
4
  const SECRET_PATTERNS = [
5
5
  [/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED_AWS_KEY]'],
6
+ // GitHub: classic PATs (ghp_), OAuth (gho_), app/refresh/server tokens
7
+ // (ghs_/ghr_), and fine-grained PATs (github_pat_). All share the 36-char
8
+ // classic body; fine-grained tokens are longer, so match greedily.
6
9
  [/\bghp_[A-Za-z0-9]{36}\b/g, '[REDACTED_GITHUB_TOKEN]'],
10
+ [/\bgh[osru]_[A-Za-z0-9]{36}\b/g, '[REDACTED_GITHUB_TOKEN]'],
11
+ [/\bgithub_pat_[A-Za-z0-9_]{22,}\b/g, '[REDACTED_GITHUB_TOKEN]'],
12
+ // Anthropic keys (sk-ant-api03-…) before the generic sk- rule so the marker
13
+ // is specific; the generic rule would otherwise swallow it first.
14
+ [/\bsk-ant-api03-[A-Za-z0-9_-]{20,}\b/g, '[REDACTED_ANTHROPIC_KEY]'],
15
+ // Stripe live secret / restricted keys.
16
+ [/\b[rs]k_live_[A-Za-z0-9]{20,}\b/g, '[REDACTED_STRIPE_KEY]'],
7
17
  [/\bsk-[A-Za-z0-9]{20,}\b/g, '[REDACTED_API_KEY]'],
18
+ // Slack bot/user/app-level tokens (xoxb-/xoxp-/xapp-…).
19
+ [/\bxox[bp]-[A-Za-z0-9-]{10,}\b/g, '[REDACTED_SLACK_TOKEN]'],
20
+ [/\bxapp-[A-Za-z0-9-]{10,}\b/g, '[REDACTED_SLACK_TOKEN]'],
8
21
  [/\bnpm_[A-Za-z0-9]{36}\b/g, '[REDACTED_NPM_TOKEN]'],
9
22
  [/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REDACTED_JWT]'],
10
23
  [/Bearer\s+\S+/gi, 'Bearer [REDACTED]'],
11
24
  [/\b([A-Z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD)[A-Z0-9_]*)=("[^"]*"|'[^']*'|\S+)/gi, '$1=[REDACTED]'],
12
25
  ];
13
- export function redactSecrets(text) {
26
+ /** Env vars whose NAME marks their VALUE as a credential worth masking literally. */
27
+ const SECRET_ENV_NAME = /(?:TOKEN|KEY|SECRET|PASSWORD)/i;
28
+ /** Don't literal-mask trivially short values — they collide with ordinary text. */
29
+ const MIN_KNOWN_VALUE_LEN = 6;
30
+ /**
31
+ * Scrub secrets from `text`. Two passes: format-based patterns (above), then a
32
+ * value-aware pass that masks any `knownValues` verbatim — a credential we
33
+ * already hold in hand leaks regardless of its format, so an exact-value match
34
+ * catches tokens the regexes don't recognize.
35
+ */
36
+ export function redactSecrets(text, knownValues) {
14
37
  let safe = text;
15
38
  for (const [pattern, replacement] of SECRET_PATTERNS) {
16
39
  safe = safe.replace(pattern, replacement);
17
40
  }
41
+ if (knownValues) {
42
+ for (const value of knownValues) {
43
+ if (value.length < MIN_KNOWN_VALUE_LEN)
44
+ continue;
45
+ safe = safe.split(value).join('[REDACTED]');
46
+ }
47
+ }
18
48
  return safe;
19
49
  }
50
+ /**
51
+ * Secret values already present in the environment (e.g. an injected secrets
52
+ * bundle), selected by secret-shaped var NAME. These are the "known" values fed
53
+ * to {@link redactSecrets} so an exported transcript can't leak a live
54
+ * credential verbatim even when its format matches no pattern.
55
+ */
56
+ export function knownSecretValuesFromEnv(env = process.env) {
57
+ const out = [];
58
+ for (const [name, value] of Object.entries(env)) {
59
+ if (!value || value.length < MIN_KNOWN_VALUE_LEN)
60
+ continue;
61
+ if (SECRET_ENV_NAME.test(name))
62
+ out.push(value);
63
+ }
64
+ return out;
65
+ }
@@ -11,7 +11,7 @@ import chalk from 'chalk';
11
11
  import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from './ssh-exec.js';
12
12
  import { sshTargetFor } from './devices/connect.js';
13
13
  import { resolveExplicitTargets } from './devices/resolve-target.js';
14
- import { loadDevices } from './devices/registry.js';
14
+ import { loadDevices, isControlDevice } from './devices/registry.js';
15
15
  import { remoteShellFor, buildWindowsAgentsCommand } from './hosts/remote-cmd.js';
16
16
  import { machineId, normalizeHost } from './machine-id.js';
17
17
  const REMOTE_TIMEOUT_MS = 12_000;
@@ -66,6 +66,12 @@ export async function gatherRemoteAgentsJson(options) {
66
66
  continue;
67
67
  if (normalizeHost(device.name) === self)
68
68
  continue;
69
+ // Control-only devices (a phone/tablet cockpit) drive the fleet but never
70
+ // run agents — never dial them, whatever their platform reads as. Keyed on
71
+ // role, not platform, so this holds even for a control device that carries
72
+ // a real OS value (mirrors the skip in session/remote-list.ts).
73
+ if (isControlDevice(device))
74
+ continue;
69
75
  if (!['windows', 'linux', 'macos'].includes(device.platform))
70
76
  continue;
71
77
  try {
@@ -182,18 +182,21 @@ export declare function rotationFailoverChain(rotation: RotateResult | null, pic
182
182
  * would break a previously-working `agents run … --loop` / `--acp`.
183
183
  * - `resumeCheckpoint` runs take the loop path (same guard).
184
184
  * - `interactive` / no-prompt runs can't be re-dispatched headlessly.
185
- * - `explicitFallback` (a user `--fallback` chain OR a profile fallback already
186
- * unshifted) defines its own recovery; don't layer rotation failover on top.
187
185
  * - `hasRotation`/`hasVersion` gate on an actual pre-flight rotation having
188
186
  * picked an account, so pinned and non-rotation runs are untouched.
189
187
  *
188
+ * An explicit `--fallback` chain does NOT disarm rotation failover: the
189
+ * synthesized same-agent entries are unshifted AHEAD of the user's cross-agent
190
+ * entries, so a rate limit exhausts the other accounts of the same agent
191
+ * before cascading to a different CLI. Profile fallbacks never reach here —
192
+ * strategy resolution is skipped for profiles, so hasRotation is false.
193
+ *
190
194
  * Pure so the arming matrix is unit-testable without invoking the run command.
191
195
  */
192
196
  export interface FailoverArmingContext {
193
197
  hasRotation: boolean;
194
198
  hasVersion: boolean;
195
199
  hasPrompt: boolean;
196
- explicitFallback: boolean;
197
200
  interactive: boolean;
198
201
  acp: boolean;
199
202
  loop: boolean;
@@ -440,7 +440,6 @@ export function shouldArmRotationFailover(ctx) {
440
440
  return (ctx.hasRotation &&
441
441
  ctx.hasVersion &&
442
442
  ctx.hasPrompt &&
443
- !ctx.explicitFallback &&
444
443
  !ctx.interactive &&
445
444
  !ctx.acp &&
446
445
  !ctx.loop &&
@@ -91,6 +91,17 @@ export interface JobConfig {
91
91
  * overdue; everywhere else it is inert and `run` refuses with a pointer.
92
92
  */
93
93
  devices?: string[];
94
+ /**
95
+ * Execution placement — run the job body on this machine over SSH (a
96
+ * registered host, device, capability tag, or user@host) instead of locally.
97
+ * Distinct from `devices`: `devices` says which daemon may FIRE the job,
98
+ * `host` says where the dispatched run EXECUTES. CLI flag: `--run-on`
99
+ * (`--host` on routines commands already means "manage routines on that
100
+ * machine" via the remote passthrough).
101
+ */
102
+ host?: string;
103
+ /** Working directory on the host for `host:`-placed runs. */
104
+ remoteCwd?: string;
94
105
  variables?: Record<string, string>;
95
106
  sandbox?: boolean;
96
107
  allow?: JobAllowConfig;
@@ -123,6 +134,11 @@ export interface RunMeta {
123
134
  startedAt: string;
124
135
  completedAt: string | null;
125
136
  exitCode: number | null;
137
+ /** Set for `host:`-placed runs — where the job body executes (no local pid). */
138
+ host?: string;
139
+ /** The host-task sidecar id backing a `host:` run; the daemon monitor
140
+ * finalizes the run by reconciling it against the remote `.exit`. */
141
+ hostTaskId?: string;
126
142
  }
127
143
  /**
128
144
  * True when the job may execute on this machine: no `devices` allowlist (or
@@ -363,6 +363,25 @@ export function validateJob(config) {
363
363
  if (config.device !== undefined) {
364
364
  errors.push('singular "device" key is no longer supported — replace with devices: [<name>] (an array)');
365
365
  }
366
+ if (config.host !== undefined) {
367
+ if (typeof config.host !== 'string' || config.host.trim() === '') {
368
+ errors.push('host must be a non-empty machine name (a registered host, device, capability tag, or user@host)');
369
+ }
370
+ // v1: the workflow bundle and the loop driver (with its signal files) live
371
+ // on the firing machine — neither can cross SSH to the target yet.
372
+ if (config.workflow) {
373
+ errors.push("host: can't be combined with workflow: yet (the bundle lives on the firing machine) — run the workflow locally or convert it to a plain prompt");
374
+ }
375
+ if (config.loop) {
376
+ errors.push("host: can't be combined with loop: yet (the loop driver and its signal files live on the firing machine)");
377
+ }
378
+ if (config.command) {
379
+ errors.push("host: can't be combined with command: yet (a plain shell command has no agent to place remotely) — run it locally, or convert it to a prompt");
380
+ }
381
+ }
382
+ if (config.remoteCwd !== undefined && config.host === undefined) {
383
+ errors.push('remoteCwd only applies to host:-placed routines — set host: too, or drop it');
384
+ }
366
385
  if (config.devices !== undefined) {
367
386
  if (!Array.isArray(config.devices)) {
368
387
  errors.push('devices must be an array of device names (as shown by `agents devices`)');
@@ -77,6 +77,7 @@ export declare function buildRoutineSpawnEnv(baseEnv: Record<string, string>, ag
77
77
  */
78
78
  export declare function executeJob(config: JobConfig, deps?: LoopDeps): Promise<RunResult>;
79
79
  /** Spawn a job as a detached process and return immediately with run metadata. */
80
+ /** Spawn a job as a detached process and return immediately with run metadata. */
80
81
  export declare function executeJobDetached(config: JobConfig): Promise<RunMeta>;
81
82
  /** Extract the final assistant message from a stream-JSON log file as a markdown report. */
82
83
  export declare function extractReport(stdoutPath: string, agentType: AgentId): string | null;
@@ -23,6 +23,8 @@ import { prepareJobHome, buildSpawnEnv } from './sandbox.js';
23
23
  import { resolveModel, buildReasoningFlags } from './models.js';
24
24
  import { createTimer, maybeRotate, redactPrompt } from './events.js';
25
25
  import { normalizeMode, buildExecEnv, detectRateLimit, } from './exec.js';
26
+ import { loadTask as loadHostTask } from './hosts/tasks.js';
27
+ import { reconcileTask as reconcileHostTask } from './hosts/reconcile.js';
26
28
  import { backgroundSpawnOptions } from './platform/process.js';
27
29
  import { getBinaryPath, isVersionInstalled, resolveVersion } from './versions.js';
28
30
  import { getConfiguredRunStrategy, resolveRunVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
@@ -448,6 +450,12 @@ export async function executeJob(config, deps) {
448
450
  if (eligibility) {
449
451
  throw new Error(eligibility.message);
450
452
  }
453
+ // `host:` placement — the job body runs on another machine over SSH; local
454
+ // version selection / sandbox / spawn do not apply. Sync callers (manual
455
+ // `routines run`, catchup) follow the remote run to completion.
456
+ if (config.host) {
457
+ return executeJobOnHost(config, { detached: false });
458
+ }
451
459
  // Command-mode: run a plain shell command directly (no agent, no rotation,
452
460
  // no pinning, no sandbox overlay). Reuses the run-record machinery so
453
461
  // list/runs/overdue keep working.
@@ -617,15 +625,67 @@ export async function executeJob(config, deps) {
617
625
  timer.end({ status: 'failed', exitCode: 1, runId });
618
626
  return { meta, reportPath: null };
619
627
  }
620
- /**
621
- * Foreground execution for a command-mode routine (`config.command`). Runs a
622
- * plain shell command in the REAL environment (no sandbox), captures stdout+
623
- * stderr to `stdout.log`, awaits completion, and honors `config.timeout` with
624
- * the same SIGTERM→SIGKILL kill mechanism the agent path uses. No agent is
625
- * spawned; the run record carries `command` instead of `agent`. There is no
626
- * agent report to extract, so `reportPath` is always null (the stdout log is
627
- * the artifact — same location the agent path writes).
628
- */
628
+ async function executeJobOnHost(config, opts) {
629
+ if (config.workflow) {
630
+ throw new Error(`Routine '${config.name}' runs a workflow bundle, which can't execute on a host yet — remove 'host:' or 'workflow:'.`);
631
+ }
632
+ if (config.loop) {
633
+ throw new Error(`Routine '${config.name}' uses 'loop:', which can't execute on a host yet — remove 'host:' or 'loop:'.`);
634
+ }
635
+ if (config.command) {
636
+ throw new Error(`Routine '${config.name}' uses 'command:', which can't execute on a host yet — remove 'host:' or 'command:'.`);
637
+ }
638
+ const { resolveHostRunTarget, dispatchPromptToHost } = await import('./hosts/run-target.js');
639
+ const host = await resolveHostRunTarget(config.host);
640
+ const timer = createTimer('agent.run', {
641
+ agent: config.agent,
642
+ jobName: config.name,
643
+ mode: config.mode,
644
+ host: host.name,
645
+ ...redactPrompt(config.prompt),
646
+ schedule: config.schedule,
647
+ });
648
+ const runId = generateRunId();
649
+ const runDir = getRunDir(config.name, runId);
650
+ fs.mkdirSync(runDir, { recursive: true });
651
+ const meta = {
652
+ jobName: config.name,
653
+ runId,
654
+ agent: config.agent,
655
+ pid: null, // no local process — the run lives on the host
656
+ spawnedAt: Date.now(),
657
+ status: 'running',
658
+ startedAt: new Date().toISOString(),
659
+ completedAt: null,
660
+ exitCode: null,
661
+ host: host.name,
662
+ };
663
+ writeRunMeta(meta);
664
+ const { task, exitCode } = await dispatchPromptToHost(host, {
665
+ agent: config.agent,
666
+ prompt: resolveJobPrompt(config),
667
+ mode: normalizeMode(config.mode),
668
+ effort: config.effort,
669
+ model: config.config?.model,
670
+ timeout: config.timeout, // enforced by the REMOTE agents run
671
+ remoteCwd: config.remoteCwd,
672
+ name: config.name,
673
+ cwd: runDir,
674
+ follow: !opts.detached,
675
+ });
676
+ meta.hostTaskId = task.id;
677
+ // Sync path: a real exit code finalizes now. -1 (follow window closed) and
678
+ // the detached path leave the meta `running` for the monitor to reconcile.
679
+ if (!opts.detached && exitCode !== null && exitCode !== undefined && exitCode !== -1) {
680
+ meta.status = exitCode === 0 ? 'completed' : 'failed';
681
+ meta.exitCode = exitCode;
682
+ meta.completedAt = new Date().toISOString();
683
+ }
684
+ writeRunMeta(meta);
685
+ timer.end({ status: meta.status, exitCode: meta.exitCode ?? undefined, runId });
686
+ return { meta, reportPath: null };
687
+ }
688
+ /** Spawn a job as a detached process and return immediately with run metadata. */
629
689
  async function executeCommandJobForeground(config) {
630
690
  const timer = createTimer('agent.run', {
631
691
  jobName: config.name,
@@ -712,12 +772,18 @@ async function executeCommandJobForeground(config) {
712
772
  return { meta, reportPath: null };
713
773
  }
714
774
  /** Spawn a job as a detached process and return immediately with run metadata. */
775
+ /** Spawn a job as a detached process and return immediately with run metadata. */
715
776
  export async function executeJobDetached(config) {
716
777
  const eligibility = checkJobDeviceEligibility(config);
717
778
  if (eligibility) {
718
779
  process.stderr.write(`[agents] daemon: skipping '${config.name}' — ${eligibility.message}\n`);
719
780
  throw new Error(eligibility.message);
720
781
  }
782
+ // `host:` placement — dispatch over SSH and return; the monitor finalizes.
783
+ if (config.host) {
784
+ const { meta } = await executeJobOnHost(config, { detached: true });
785
+ return meta;
786
+ }
721
787
  // Command-mode: fire a plain shell command detached (no agent, no rotation,
722
788
  // no pinning, no sandbox overlay). Still writes a run record so the daemon,
723
789
  // list/runs, and overdue tracking keep working.
@@ -994,6 +1060,26 @@ function isPidOurs(pid, spawnedAt) {
994
1060
  return true;
995
1061
  }
996
1062
  }
1063
+ /**
1064
+ * Finalize one `host:`-placed run by healing its host-task sidecar against the
1065
+ * remote `.exit` (lib/hosts/reconcile.ts). Mutates + persists the meta only
1066
+ * when the sidecar reached a terminal state.
1067
+ */
1068
+ function finalizeHostRun(meta) {
1069
+ try {
1070
+ const task = loadHostTask(meta.hostTaskId);
1071
+ if (!task)
1072
+ return;
1073
+ const healed = reconcileHostTask(task);
1074
+ if (healed.status !== 'completed' && healed.status !== 'failed')
1075
+ return;
1076
+ meta.status = healed.status;
1077
+ meta.exitCode = healed.exitCode ?? (healed.status === 'completed' ? 0 : 1);
1078
+ meta.completedAt = healed.finishedAt ?? new Date().toISOString();
1079
+ writeRunMeta(meta);
1080
+ }
1081
+ catch { /* unreachable host or unreadable sidecar — retry next sweep */ }
1082
+ }
997
1083
  /** Scan all runs marked "running" and finalize any whose process has exited. */
998
1084
  export function monitorRunningJobs() {
999
1085
  const runsDir = getRunsDir();
@@ -1013,6 +1099,13 @@ export function monitorRunningJobs() {
1013
1099
  const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
1014
1100
  if (meta.status !== 'running')
1015
1101
  continue;
1102
+ // `host:`-placed run — no local pid to watch. Reconcile against the
1103
+ // remote `.exit` (completion is confirmed, never guessed: an
1104
+ // unreachable host leaves the run `running` for the next sweep).
1105
+ if (meta.hostTaskId) {
1106
+ finalizeHostRun(meta);
1107
+ continue;
1108
+ }
1016
1109
  if (!meta.pid)
1017
1110
  continue;
1018
1111
  const runDirPath = path.join(jobRunsPath, runDirEntry.name);