@phnx-labs/agents-cli 1.20.64 → 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 (155) hide show
  1. package/CHANGELOG.md +39 -3
  2. package/README.md +37 -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 +94 -49
  10. package/dist/commands/feed.js +25 -11
  11. package/dist/commands/hosts.js +44 -6
  12. package/dist/commands/mcp.js +55 -5
  13. package/dist/commands/monitors.d.ts +12 -0
  14. package/dist/commands/monitors.js +740 -0
  15. package/dist/commands/output.js +2 -2
  16. package/dist/commands/routines.js +23 -2
  17. package/dist/commands/secrets.d.ts +16 -0
  18. package/dist/commands/secrets.js +215 -64
  19. package/dist/commands/serve.js +31 -0
  20. package/dist/commands/sessions-export.js +8 -3
  21. package/dist/commands/sessions.d.ts +16 -0
  22. package/dist/commands/sessions.js +36 -6
  23. package/dist/commands/ssh.js +45 -2
  24. package/dist/commands/versions.js +7 -3
  25. package/dist/commands/view.d.ts +26 -0
  26. package/dist/commands/view.js +32 -9
  27. package/dist/commands/webhook.js +10 -2
  28. package/dist/index.js +34 -14
  29. package/dist/lib/agents.d.ts +18 -0
  30. package/dist/lib/agents.js +53 -3
  31. package/dist/lib/auto-dispatch-provider.js +7 -2
  32. package/dist/lib/auto-dispatch.d.ts +3 -0
  33. package/dist/lib/auto-dispatch.js +3 -0
  34. package/dist/lib/browser/chrome.js +2 -2
  35. package/dist/lib/cloud/antigravity.js +2 -2
  36. package/dist/lib/cloud/host.d.ts +59 -0
  37. package/dist/lib/cloud/host.js +224 -0
  38. package/dist/lib/cloud/registry.js +4 -0
  39. package/dist/lib/cloud/types.d.ts +6 -4
  40. package/dist/lib/computer-rpc.js +3 -1
  41. package/dist/lib/crabbox/cli.js +5 -1
  42. package/dist/lib/crabbox/runtimes.js +11 -2
  43. package/dist/lib/daemon.d.ts +20 -4
  44. package/dist/lib/daemon.js +62 -19
  45. package/dist/lib/devices/fleet.d.ts +3 -2
  46. package/dist/lib/devices/fleet.js +9 -0
  47. package/dist/lib/devices/registry.d.ts +15 -0
  48. package/dist/lib/devices/registry.js +9 -0
  49. package/dist/lib/exec.d.ts +19 -2
  50. package/dist/lib/exec.js +41 -13
  51. package/dist/lib/fleet/apply.d.ts +63 -0
  52. package/dist/lib/fleet/apply.js +214 -0
  53. package/dist/lib/fleet/auth-sync.d.ts +67 -0
  54. package/dist/lib/fleet/auth-sync.js +142 -0
  55. package/dist/lib/fleet/manifest.d.ts +29 -0
  56. package/dist/lib/fleet/manifest.js +127 -0
  57. package/dist/lib/fleet/types.d.ts +129 -0
  58. package/dist/lib/fleet/types.js +13 -0
  59. package/dist/lib/git.d.ts +27 -0
  60. package/dist/lib/git.js +34 -2
  61. package/dist/lib/hosts/dispatch.d.ts +29 -8
  62. package/dist/lib/hosts/dispatch.js +46 -18
  63. package/dist/lib/hosts/passthrough.js +2 -0
  64. package/dist/lib/hosts/providers/devices.d.ts +27 -0
  65. package/dist/lib/hosts/providers/devices.js +98 -0
  66. package/dist/lib/hosts/registry.d.ts +10 -16
  67. package/dist/lib/hosts/registry.js +17 -50
  68. package/dist/lib/hosts/remote-cmd.d.ts +23 -0
  69. package/dist/lib/hosts/remote-cmd.js +71 -0
  70. package/dist/lib/hosts/run-target.d.ts +84 -0
  71. package/dist/lib/hosts/run-target.js +99 -0
  72. package/dist/lib/hosts/types.d.ts +23 -5
  73. package/dist/lib/hosts/types.js +22 -4
  74. package/dist/lib/linear-autoclose.d.ts +30 -0
  75. package/dist/lib/linear-autoclose.js +22 -0
  76. package/dist/lib/mcp.d.ts +27 -1
  77. package/dist/lib/mcp.js +126 -12
  78. package/dist/lib/monitors/config.d.ts +161 -0
  79. package/dist/lib/monitors/config.js +372 -0
  80. package/dist/lib/monitors/dispatch.d.ts +28 -0
  81. package/dist/lib/monitors/dispatch.js +91 -0
  82. package/dist/lib/monitors/engine.d.ts +61 -0
  83. package/dist/lib/monitors/engine.js +201 -0
  84. package/dist/lib/monitors/sources/command.d.ts +11 -0
  85. package/dist/lib/monitors/sources/command.js +31 -0
  86. package/dist/lib/monitors/sources/device.d.ts +13 -0
  87. package/dist/lib/monitors/sources/device.js +35 -0
  88. package/dist/lib/monitors/sources/file.d.ts +14 -0
  89. package/dist/lib/monitors/sources/file.js +57 -0
  90. package/dist/lib/monitors/sources/http.d.ts +10 -0
  91. package/dist/lib/monitors/sources/http.js +34 -0
  92. package/dist/lib/monitors/sources/index.d.ts +14 -0
  93. package/dist/lib/monitors/sources/index.js +31 -0
  94. package/dist/lib/monitors/sources/poll.d.ts +9 -0
  95. package/dist/lib/monitors/sources/poll.js +9 -0
  96. package/dist/lib/monitors/sources/types.d.ts +18 -0
  97. package/dist/lib/monitors/sources/types.js +9 -0
  98. package/dist/lib/monitors/sources/webhook.d.ts +23 -0
  99. package/dist/lib/monitors/sources/webhook.js +47 -0
  100. package/dist/lib/monitors/sources/ws.d.ts +14 -0
  101. package/dist/lib/monitors/sources/ws.js +45 -0
  102. package/dist/lib/monitors/state.d.ts +69 -0
  103. package/dist/lib/monitors/state.js +144 -0
  104. package/dist/lib/platform/exec.d.ts +16 -0
  105. package/dist/lib/platform/exec.js +17 -0
  106. package/dist/lib/plugins.js +101 -2
  107. package/dist/lib/redact.d.ts +14 -1
  108. package/dist/lib/redact.js +47 -1
  109. package/dist/lib/remote-agents-json.js +7 -1
  110. package/dist/lib/rotate.d.ts +6 -3
  111. package/dist/lib/rotate.js +0 -1
  112. package/dist/lib/routines.d.ts +16 -0
  113. package/dist/lib/routines.js +19 -0
  114. package/dist/lib/runner.d.ts +1 -0
  115. package/dist/lib/runner.js +102 -9
  116. package/dist/lib/secrets/agent.d.ts +48 -10
  117. package/dist/lib/secrets/agent.js +123 -15
  118. package/dist/lib/secrets/bundles.d.ts +26 -0
  119. package/dist/lib/secrets/bundles.js +59 -8
  120. package/dist/lib/secrets/mcp.js +4 -2
  121. package/dist/lib/secrets/remote.d.ts +17 -0
  122. package/dist/lib/secrets/remote.js +40 -0
  123. package/dist/lib/self-update.d.ts +20 -0
  124. package/dist/lib/self-update.js +54 -1
  125. package/dist/lib/serve/control.d.ts +95 -0
  126. package/dist/lib/serve/control.js +260 -0
  127. package/dist/lib/serve/server.d.ts +35 -1
  128. package/dist/lib/serve/server.js +106 -76
  129. package/dist/lib/serve/stream.d.ts +43 -0
  130. package/dist/lib/serve/stream.js +116 -0
  131. package/dist/lib/serve/token.d.ts +35 -0
  132. package/dist/lib/serve/token.js +85 -0
  133. package/dist/lib/session/bundle.d.ts +14 -0
  134. package/dist/lib/session/bundle.js +12 -1
  135. package/dist/lib/session/remote-list.js +5 -1
  136. package/dist/lib/session/state.d.ts +7 -25
  137. package/dist/lib/session/state.js +16 -6
  138. package/dist/lib/session/sync/config.js +8 -2
  139. package/dist/lib/session/types.d.ts +30 -0
  140. package/dist/lib/ssh-tunnel.d.ts +19 -1
  141. package/dist/lib/ssh-tunnel.js +86 -7
  142. package/dist/lib/startup/command-registry.d.ts +2 -0
  143. package/dist/lib/startup/command-registry.js +4 -0
  144. package/dist/lib/state.d.ts +5 -0
  145. package/dist/lib/state.js +12 -0
  146. package/dist/lib/tmux/session.d.ts +7 -0
  147. package/dist/lib/tmux/session.js +3 -1
  148. package/dist/lib/triggers/webhook.d.ts +18 -0
  149. package/dist/lib/triggers/webhook.js +105 -0
  150. package/dist/lib/types.d.ts +26 -1
  151. package/dist/lib/usage.js +7 -5
  152. package/dist/lib/versions.js +14 -11
  153. package/dist/lib/workflows.d.ts +20 -0
  154. package/dist/lib/workflows.js +24 -0
  155. package/package.json +2 -1
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Native per-monitor state-diff store.
3
+ *
4
+ * This is the one genuinely new primitive monitors add over routines: a
5
+ * last-observed-*value* store. Routines persist per-*run* metadata but have no
6
+ * last-seen value, so hand-built watchers (the RUSH-1107 SSL watcher) re-invented
7
+ * state-diffing through a markdown memory file every time. This store kills that.
8
+ *
9
+ * Layout (sibling of the runs layout, atomic writes like writeRunMeta):
10
+ * ~/.agents/.history/monitors/<name>/state.json # last-seen hash/value + fire bookkeeping
11
+ * ~/.agents/.history/monitors/<name>/fires/<id>/… # fire history
12
+ */
13
+ import type { MonitorEvent } from './config.js';
14
+ /** Persisted last-seen state for one monitor. */
15
+ export interface MonitorState {
16
+ monitorName: string;
17
+ /** Hash of the last-seen de-dupe signature (see hasChanged). */
18
+ lastHash: string;
19
+ /** The last-seen raw observation (truncated for storage). */
20
+ lastValue: string;
21
+ /** RFC3339 timestamp of the last observation. */
22
+ lastSeenAt: string;
23
+ /** RFC3339 timestamp of the last fire, when the monitor has ever fired. */
24
+ lastFiredAt?: string;
25
+ /** Epoch-ms timestamps of recent fires, for the rate-limit / firehose guard. */
26
+ fireTimes?: number[];
27
+ }
28
+ /** Per-monitor history root, with the (untrusted) name contained to one segment. */
29
+ export declare function getMonitorHistoryDir(name: string): string;
30
+ /** Directory holding a monitor's fire history. */
31
+ export declare function getMonitorFiresDir(name: string): string;
32
+ /** Read a monitor's last-seen state, or null if it has never been observed. */
33
+ export declare function readState(name: string): MonitorState | null;
34
+ /** Persist a monitor's state atomically (temp file + rename, like writeRunMeta). */
35
+ export declare function writeStateRaw(state: MonitorState): void;
36
+ /**
37
+ * Record a new observation as the monitor's last-seen state, preserving fire
38
+ * bookkeeping. Truncates the stored raw value so a firehose can't bloat disk.
39
+ */
40
+ export declare function writeState(name: string, value: string, dedupeKey?: string, extra?: Partial<Pick<MonitorState, 'lastFiredAt' | 'fireTimes'>>): MonitorState;
41
+ /**
42
+ * The de-dupe signature for an observation. When `dedupeKey` is set, the
43
+ * signature is the first regex match of dedupeKey against the observation (so
44
+ * "the same event" is same matched token); otherwise it is the full observation.
45
+ * An unmatched dedupeKey falls back to the full observation.
46
+ */
47
+ export declare function dedupeSignature(observation: string, dedupeKey?: string): string;
48
+ /**
49
+ * True when `observation`'s de-dupe signature differs from the monitor's
50
+ * last-seen signature (or the monitor has never been observed). Pure read — the
51
+ * caller persists the new value via writeState only on a real fire.
52
+ */
53
+ export declare function hasChanged(name: string, observation: string, dedupeKey?: string): boolean;
54
+ /**
55
+ * Append a fire timestamp and return the pruned window (fires within `windowMs`).
56
+ * The engine uses the returned length to decide whether the rate limit tripped.
57
+ */
58
+ export declare function recordFireTime(name: string, now: number, windowMs: number): number[];
59
+ /** Write a fire record to fires/<id>/event.json and return the fire id. */
60
+ export declare function writeFireRecord(event: MonitorEvent, meta?: Record<string, unknown>): string;
61
+ /** A single fire history entry (as read back from disk). */
62
+ export interface FireRecord extends MonitorEvent {
63
+ runId?: string;
64
+ action?: string;
65
+ ok?: boolean;
66
+ error?: string;
67
+ }
68
+ /** List a monitor's fire history, chronologically ascending. */
69
+ export declare function listFires(name: string): FireRecord[];
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Native per-monitor state-diff store.
3
+ *
4
+ * This is the one genuinely new primitive monitors add over routines: a
5
+ * last-observed-*value* store. Routines persist per-*run* metadata but have no
6
+ * last-seen value, so hand-built watchers (the RUSH-1107 SSL watcher) re-invented
7
+ * state-diffing through a markdown memory file every time. This store kills that.
8
+ *
9
+ * Layout (sibling of the runs layout, atomic writes like writeRunMeta):
10
+ * ~/.agents/.history/monitors/<name>/state.json # last-seen hash/value + fire bookkeeping
11
+ * ~/.agents/.history/monitors/<name>/fires/<id>/… # fire history
12
+ */
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+ import { createHash } from 'crypto';
16
+ import { getMonitorsHistoryDir, ensureAgentsDir } from '../state.js';
17
+ import { safeJoin } from '../paths.js';
18
+ const MAX_STORED_VALUE = 4096;
19
+ /** Per-monitor history root, with the (untrusted) name contained to one segment. */
20
+ export function getMonitorHistoryDir(name) {
21
+ return safeJoin(getMonitorsHistoryDir(), name);
22
+ }
23
+ function getStatePath(name) {
24
+ return path.join(getMonitorHistoryDir(name), 'state.json');
25
+ }
26
+ /** Directory holding a monitor's fire history. */
27
+ export function getMonitorFiresDir(name) {
28
+ return path.join(getMonitorHistoryDir(name), 'fires');
29
+ }
30
+ /** Read a monitor's last-seen state, or null if it has never been observed. */
31
+ export function readState(name) {
32
+ const statePath = getStatePath(name);
33
+ if (!fs.existsSync(statePath))
34
+ return null;
35
+ try {
36
+ return JSON.parse(fs.readFileSync(statePath, 'utf-8'));
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ /** Persist a monitor's state atomically (temp file + rename, like writeRunMeta). */
43
+ export function writeStateRaw(state) {
44
+ ensureAgentsDir();
45
+ const dir = getMonitorHistoryDir(state.monitorName);
46
+ fs.mkdirSync(dir, { recursive: true });
47
+ const statePath = path.join(dir, 'state.json');
48
+ const tmp = `${statePath}.tmp-${process.pid}`;
49
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf-8');
50
+ fs.renameSync(tmp, statePath);
51
+ }
52
+ /**
53
+ * Record a new observation as the monitor's last-seen state, preserving fire
54
+ * bookkeeping. Truncates the stored raw value so a firehose can't bloat disk.
55
+ */
56
+ export function writeState(name, value, dedupeKey, extra = {}) {
57
+ const prev = readState(name);
58
+ const state = {
59
+ monitorName: name,
60
+ lastHash: hashSignature(value, dedupeKey),
61
+ lastValue: value.length > MAX_STORED_VALUE ? value.slice(0, MAX_STORED_VALUE) : value,
62
+ lastSeenAt: new Date().toISOString(),
63
+ ...(prev?.lastFiredAt ? { lastFiredAt: prev.lastFiredAt } : {}),
64
+ ...(prev?.fireTimes ? { fireTimes: prev.fireTimes } : {}),
65
+ ...extra,
66
+ };
67
+ writeStateRaw(state);
68
+ return state;
69
+ }
70
+ /**
71
+ * The de-dupe signature for an observation. When `dedupeKey` is set, the
72
+ * signature is the first regex match of dedupeKey against the observation (so
73
+ * "the same event" is same matched token); otherwise it is the full observation.
74
+ * An unmatched dedupeKey falls back to the full observation.
75
+ */
76
+ export function dedupeSignature(observation, dedupeKey) {
77
+ if (!dedupeKey)
78
+ return observation;
79
+ try {
80
+ const m = new RegExp(dedupeKey).exec(observation);
81
+ if (m)
82
+ return m[1] ?? m[0];
83
+ }
84
+ catch {
85
+ /* invalid regex — fall back to full observation */
86
+ }
87
+ return observation;
88
+ }
89
+ function hashSignature(observation, dedupeKey) {
90
+ return createHash('sha256').update(dedupeSignature(observation, dedupeKey)).digest('hex');
91
+ }
92
+ /**
93
+ * True when `observation`'s de-dupe signature differs from the monitor's
94
+ * last-seen signature (or the monitor has never been observed). Pure read — the
95
+ * caller persists the new value via writeState only on a real fire.
96
+ */
97
+ export function hasChanged(name, observation, dedupeKey) {
98
+ const prev = readState(name);
99
+ if (!prev)
100
+ return true;
101
+ return prev.lastHash !== hashSignature(observation, dedupeKey);
102
+ }
103
+ /**
104
+ * Append a fire timestamp and return the pruned window (fires within `windowMs`).
105
+ * The engine uses the returned length to decide whether the rate limit tripped.
106
+ */
107
+ export function recordFireTime(name, now, windowMs) {
108
+ const prev = readState(name);
109
+ const times = [...(prev?.fireTimes ?? []), now].filter((t) => now - t <= windowMs);
110
+ return times;
111
+ }
112
+ /** Write a fire record to fires/<id>/event.json and return the fire id. */
113
+ export function writeFireRecord(event, meta = {}) {
114
+ ensureAgentsDir();
115
+ const fireId = event.firedAt.replace(/[:.]/g, '-');
116
+ const fireDir = safeJoin(getMonitorFiresDir(event.monitorName), fireId);
117
+ fs.mkdirSync(fireDir, { recursive: true });
118
+ fs.writeFileSync(path.join(fireDir, 'event.json'), JSON.stringify({ ...event, ...meta }, null, 2), 'utf-8');
119
+ return fireId;
120
+ }
121
+ /** List a monitor's fire history, chronologically ascending. */
122
+ export function listFires(name) {
123
+ const dir = getMonitorFiresDir(name);
124
+ if (!fs.existsSync(dir))
125
+ return [];
126
+ const ids = fs
127
+ .readdirSync(dir, { withFileTypes: true })
128
+ .filter((e) => e.isDirectory())
129
+ .map((e) => e.name)
130
+ .sort();
131
+ const fires = [];
132
+ for (const id of ids) {
133
+ const eventPath = path.join(dir, id, 'event.json');
134
+ if (!fs.existsSync(eventPath))
135
+ continue;
136
+ try {
137
+ fires.push(JSON.parse(fs.readFileSync(eventPath, 'utf-8')));
138
+ }
139
+ catch {
140
+ /* skip corrupt record */
141
+ }
142
+ }
143
+ return fires;
144
+ }
@@ -66,3 +66,19 @@ export declare function quoteWin32ExecArg(arg: string): string;
66
66
  * line is byte-identical to the old unquoted join for the common case.
67
67
  */
68
68
  export declare function composeWin32CommandLine(command: string, args: string[]): string;
69
+ /**
70
+ * DEP0190-safe execFile/spawn spec when the binary may need `shell: true` on Windows.
71
+ *
72
+ * When `needsWindowsShell(bin)` is true, compose ONE fully-quoted command line via
73
+ * `composeWin32CommandLine` and return an EMPTY args array so Node never concatenates
74
+ * user-controlled argv (MCP command/args, prompts, …) into the cmd.exe line unescaped.
75
+ * Off that path the original bin+args are returned unchanged.
76
+ *
77
+ * Call as: `const s = execFileShellSpec(bin, args); execFile(s.command, s.args, { shell: s.shell })`.
78
+ * Pure; optional `platform` override keeps the win32 branch unit-testable on any host.
79
+ */
80
+ export declare function execFileShellSpec(bin: string, args: string[], platform?: NodeJS.Platform): {
81
+ command: string;
82
+ args: string[];
83
+ shell: boolean;
84
+ };
@@ -119,3 +119,20 @@ export function quoteWin32ExecArg(arg) {
119
119
  export function composeWin32CommandLine(command, args) {
120
120
  return [command, ...args].map(quoteWin32ExecArg).join(' ');
121
121
  }
122
+ /**
123
+ * DEP0190-safe execFile/spawn spec when the binary may need `shell: true` on Windows.
124
+ *
125
+ * When `needsWindowsShell(bin)` is true, compose ONE fully-quoted command line via
126
+ * `composeWin32CommandLine` and return an EMPTY args array so Node never concatenates
127
+ * user-controlled argv (MCP command/args, prompts, …) into the cmd.exe line unescaped.
128
+ * Off that path the original bin+args are returned unchanged.
129
+ *
130
+ * Call as: `const s = execFileShellSpec(bin, args); execFile(s.command, s.args, { shell: s.shell })`.
131
+ * Pure; optional `platform` override keeps the win32 branch unit-testable on any host.
132
+ */
133
+ export function execFileShellSpec(bin, args, platform = process.platform) {
134
+ if (!needsWindowsShell(bin, platform)) {
135
+ return { command: bin, args, shell: false };
136
+ }
137
+ return { command: composeWin32CommandLine(bin, args), args: [], shell: true };
138
+ }
@@ -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);
@@ -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;