@phnx-labs/agents-cli 1.22.42 → 1.22.44
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.
- package/CHANGELOG.md +149 -0
- package/README.md +15 -13
- package/dist/bootstrap.js +33 -13
- package/dist/cli/command-registry.d.ts +0 -8
- package/dist/cli/command-registry.js +1 -25
- package/dist/commands/alias.d.ts +7 -4
- package/dist/commands/alias.js +33 -8
- package/dist/commands/apply.d.ts +7 -10
- package/dist/commands/apply.js +15 -35
- package/dist/commands/artifacts.js +4 -5
- package/dist/commands/audit.d.ts +6 -6
- package/dist/commands/audit.js +12 -12
- package/dist/commands/auth.js +14 -6
- package/dist/commands/beta.d.ts +7 -1
- package/dist/commands/beta.js +16 -9
- package/dist/commands/commands.js +1 -1
- package/dist/commands/computer.js +20 -6
- package/dist/commands/daemon.js +1 -1
- package/dist/commands/doctor.d.ts +4 -2
- package/dist/commands/doctor.js +21 -9
- package/dist/commands/events.d.ts +2 -2
- package/dist/commands/events.js +6 -5
- package/dist/commands/exec.d.ts +1 -1
- package/dist/commands/exec.js +11 -6
- package/dist/commands/factory.d.ts +1 -1
- package/dist/commands/feed.js +1 -30
- package/dist/commands/fleet-capture.d.ts +1 -1
- package/dist/commands/fleet-capture.js +4 -5
- package/dist/commands/harness.js +4 -52
- package/dist/commands/hooks.js +1 -1
- package/dist/commands/insights.d.ts +1 -1
- package/dist/commands/insights.js +3 -2
- package/dist/commands/menubar.js +58 -1
- package/dist/commands/monitors.js +1 -1
- package/dist/commands/org.d.ts +5 -0
- package/dist/commands/org.js +83 -29
- package/dist/commands/output.js +2 -2
- package/dist/commands/profiles.js +2 -2
- package/dist/commands/prune.js +5 -4
- package/dist/commands/routines.js +43 -25
- package/dist/commands/routines.test-fixture.d.ts +86 -0
- package/dist/commands/routines.test-fixture.js +345 -0
- package/dist/commands/sessions-picker.js +124 -122
- package/dist/commands/sessions.test-fixture.d.ts +24 -0
- package/dist/commands/sessions.test-fixture.js +224 -0
- package/dist/commands/setup.js +9 -1
- package/dist/commands/share.d.ts +9 -10
- package/dist/commands/share.js +68 -50
- package/dist/commands/skills.js +1 -1
- package/dist/commands/snapshot.d.ts +1 -1
- package/dist/commands/snapshot.js +4 -4
- package/dist/commands/ssh.js +15 -62
- package/dist/commands/status.d.ts +4 -2
- package/dist/commands/status.js +10 -7
- package/dist/commands/subagents.js +1 -1
- package/dist/commands/sync.d.ts +1 -0
- package/dist/commands/sync.js +29 -2
- package/dist/commands/view.js +11 -2
- package/dist/commands/webhook.js +1 -1
- package/dist/lib/accounting/rotate.d.ts +5 -3
- package/dist/lib/accounting/rotate.js +36 -7
- package/dist/lib/accounting/usage.d.ts +18 -4
- package/dist/lib/accounting/usage.js +138 -27
- package/dist/lib/agent-spec/agents.d.ts +7 -7
- package/dist/lib/agent-spec/agents.js +15 -15
- package/dist/lib/analytics/mix-commands.d.ts +6 -28
- package/dist/lib/analytics/mix-commands.js +14 -40
- package/dist/lib/audit/log.d.ts +2 -2
- package/dist/lib/audit/log.js +2 -2
- package/dist/lib/beta.js +1 -1
- package/dist/lib/browser/domain-skills.d.ts +26 -6
- package/dist/lib/browser/domain-skills.js +81 -43
- package/dist/lib/cloud/rush.d.ts +0 -15
- package/dist/lib/cloud/rush.js +0 -35
- package/dist/lib/crabbox/runtimes.d.ts +6 -2
- package/dist/lib/crabbox/runtimes.js +21 -5
- package/dist/lib/daemon/daemon.js +97 -58
- package/dist/lib/daemon/daemon.test-fixture.d.ts +24 -0
- package/dist/lib/daemon/daemon.test-fixture.js +71 -0
- package/dist/lib/daemon/runner.d.ts +18 -1
- package/dist/lib/daemon/runner.js +105 -11
- package/dist/lib/daemon-ticks.d.ts +38 -1
- package/dist/lib/daemon-ticks.js +52 -5
- package/dist/lib/device-config.js +1 -2
- package/dist/lib/devices/discovery-policy.d.ts +3 -3
- package/dist/lib/devices/discovery-policy.js +3 -3
- package/dist/lib/devices/doctor-findings.js +1 -1
- package/dist/lib/devices/fleet.d.ts +4 -8
- package/dist/lib/devices/fleet.js +3 -15
- package/dist/lib/devices/health-report.js +4 -4
- package/dist/lib/devices/pool.d.ts +0 -6
- package/dist/lib/devices/pool.js +0 -6
- package/dist/lib/devices/registry.d.ts +0 -15
- package/dist/lib/devices/registry.js +0 -9
- package/dist/lib/devices/stats-cache.d.ts +12 -0
- package/dist/lib/devices/stats-cache.js +24 -5
- package/dist/lib/drift-sync.d.ts +1 -1
- package/dist/lib/drift-sync.js +2 -2
- package/dist/lib/drift.d.ts +1 -1
- package/dist/lib/exec.js +13 -3
- package/dist/lib/fleet/capture.d.ts +1 -1
- package/dist/lib/fleet/manifest.js +1 -1
- package/dist/lib/fleet/remote-login.d.ts +2 -2
- package/dist/lib/fleet/remote-login.js +3 -4
- package/dist/lib/format.d.ts +9 -0
- package/dist/lib/format.js +16 -0
- package/dist/lib/github/pr-mergeable.d.ts +49 -0
- package/dist/lib/github/pr-mergeable.js +157 -0
- package/dist/lib/github/pr-verdict.d.ts +67 -0
- package/dist/lib/github/pr-verdict.js +72 -0
- package/dist/lib/harness/adapter.d.ts +6 -5
- package/dist/lib/hosts/dispatch.d.ts +2 -0
- package/dist/lib/hosts/dispatch.js +8 -1
- package/dist/lib/hosts/passthrough.d.ts +10 -0
- package/dist/lib/hosts/passthrough.js +28 -7
- package/dist/lib/hosts/providers/devices.js +1 -13
- package/dist/lib/hosts/ready.d.ts +12 -3
- package/dist/lib/hosts/ready.js +27 -12
- package/dist/lib/hosts/registry.d.ts +4 -5
- package/dist/lib/hosts/registry.js +3 -7
- package/dist/lib/hosts/session-index.js +1 -1
- package/dist/lib/installations/shims.js +1 -1
- package/dist/lib/installations/versions.d.ts +40 -0
- package/dist/lib/installations/versions.js +78 -4
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +1 -5
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -15
- package/dist/lib/menubar/install-menubar.d.ts +109 -0
- package/dist/lib/menubar/install-menubar.js +218 -12
- package/dist/lib/monitors/config.d.ts +2 -2
- package/dist/lib/monitors/config.js +3 -2
- package/dist/lib/origin-machine.d.ts +18 -0
- package/dist/lib/origin-machine.js +34 -0
- package/dist/lib/perf/db.js +1 -1
- package/dist/lib/perf/spool.js +1 -1
- package/dist/lib/prix-account.d.ts +2 -1
- package/dist/lib/prix-account.js +2 -1
- package/dist/lib/profiles.d.ts +7 -0
- package/dist/lib/profiles.js +26 -1
- package/dist/lib/remote-agents-json.js +1 -7
- package/dist/lib/routines-placement.d.ts +1 -1
- package/dist/lib/routines-placement.js +1 -1
- package/dist/lib/sandbox.d.ts +24 -0
- package/dist/lib/sandbox.js +101 -0
- package/dist/lib/scheduling/routines.d.ts +18 -9
- package/dist/lib/scheduling/routines.js +16 -8
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
- package/dist/lib/secrets/agent.js +9 -1
- package/dist/lib/secrets/index.js +1 -1
- package/dist/lib/secrets/list-filter.js +1 -1
- package/dist/lib/service-manifest.d.ts +20 -0
- package/dist/lib/service-manifest.js +34 -0
- package/dist/lib/session/active.d.ts +4 -16
- package/dist/lib/session/active.js +2 -6
- package/dist/lib/session/cloud.js +1 -1
- package/dist/lib/session/db.d.ts +13 -98
- package/dist/lib/session/db.js +5 -12
- package/dist/lib/session/discover.d.ts +5 -81
- package/dist/lib/session/discover.js +5 -5
- package/dist/lib/session/fork.js +1 -1
- package/dist/lib/session/live-metadata.js +1 -1
- package/dist/lib/session/origin-machine.d.ts +1 -18
- package/dist/lib/session/origin-machine.js +4 -34
- package/dist/lib/session/relative-time.d.ts +1 -44
- package/dist/lib/session/relative-time.js +4 -120
- package/dist/lib/session/remote/remote-bundle.d.ts +12 -0
- package/dist/lib/session/remote/remote-bundle.js +61 -0
- package/dist/lib/session/remote/remote-list.d.ts +148 -0
- package/dist/lib/session/remote/remote-list.js +607 -0
- package/dist/lib/session/remote/remote.d.ts +132 -0
- package/dist/lib/session/remote/remote.js +314 -0
- package/dist/lib/session/remote/watch.d.ts +101 -0
- package/dist/lib/session/remote/watch.js +241 -0
- package/dist/lib/session/remote-bundle.d.ts +1 -12
- package/dist/lib/session/remote-bundle.js +3 -61
- package/dist/lib/session/remote-list.d.ts +1 -148
- package/dist/lib/session/remote-list.js +3 -607
- package/dist/lib/session/remote.d.ts +1 -132
- package/dist/lib/session/remote.js +3 -314
- package/dist/lib/session/short-id.d.ts +1 -17
- package/dist/lib/session/short-id.js +4 -20
- package/dist/lib/session/watch.d.ts +1 -101
- package/dist/lib/session/watch.js +3 -242
- package/dist/lib/session/width.d.ts +1 -29
- package/dist/lib/session/width.js +4 -101
- package/dist/lib/share/config.d.ts +12 -2
- package/dist/lib/share/config.js +47 -11
- package/dist/lib/share/delete.js +1 -1
- package/dist/lib/share/publish.js +1 -1
- package/dist/lib/share/worker-template.js +2 -2
- package/dist/lib/smart-launch.d.ts +4 -4
- package/dist/lib/smart-launch.js +8 -18
- package/dist/lib/snapshot.d.ts +2 -2
- package/dist/lib/snapshot.js +1 -1
- package/dist/lib/staleness/index.d.ts +3 -1
- package/dist/lib/staleness/index.js +21 -1
- package/dist/lib/staleness/types.d.ts +11 -0
- package/dist/lib/staleness/writers/commands.js +13 -4
- package/dist/lib/staleness/writers/hooks.js +3 -1
- package/dist/lib/staleness/writers/mcp.d.ts +0 -7
- package/dist/lib/staleness/writers/mcp.js +16 -1
- package/dist/lib/staleness/writers/rules.js +1 -1
- package/dist/lib/staleness/writers/skills.js +3 -1
- package/dist/lib/staleness/writers/subagents.d.ts +0 -9
- package/dist/lib/staleness/writers/subagents.js +18 -1
- package/dist/lib/staleness/writers/types.d.ts +10 -0
- package/dist/lib/startup/command-registry.d.ts +10 -1
- package/dist/lib/startup/command-registry.js +26 -7
- package/dist/lib/startup/root-command.d.ts +18 -1
- package/dist/lib/startup/root-command.js +18 -1
- package/dist/lib/state.js +7 -0
- package/dist/lib/teams/placement-probe.js +28 -13
- package/dist/lib/teams/scheduler.d.ts +3 -1
- package/dist/lib/teams/scheduler.js +1 -1
- package/dist/lib/text/relative-time.d.ts +44 -0
- package/dist/lib/text/relative-time.js +120 -0
- package/dist/lib/text/short-id.d.ts +17 -0
- package/dist/lib/text/short-id.js +20 -0
- package/dist/lib/text/width.d.ts +29 -0
- package/dist/lib/text/width.js +101 -0
- package/dist/lib/triggers/handlers.d.ts +7 -0
- package/dist/lib/triggers/handlers.js +8 -0
- package/dist/lib/triggers/webhook.d.ts +1 -0
- package/dist/lib/triggers/webhook.js +2 -1
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/view-types.d.ts +7 -0
- package/package.json +2 -3
- package/dist/bin/agents +0 -0
- package/dist/commands/serve.d.ts +0 -10
- package/dist/commands/serve.js +0 -68
- package/dist/commands/tickets.d.ts +0 -2
- package/dist/commands/tickets.js +0 -43
- package/dist/commands/trends.d.ts +0 -10
- package/dist/commands/trends.js +0 -12
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/observe-aliases.d.ts +0 -30
- package/dist/lib/observe-aliases.js +0 -38
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/serve/control.d.ts +0 -95
- package/dist/lib/serve/control.js +0 -260
- package/dist/lib/serve/data.d.ts +0 -81
- package/dist/lib/serve/data.js +0 -91
- package/dist/lib/serve/page.d.ts +0 -7
- package/dist/lib/serve/page.js +0 -140
- package/dist/lib/serve/server.d.ts +0 -80
- package/dist/lib/serve/server.js +0 -145
- package/dist/lib/serve/stream.d.ts +0 -43
- package/dist/lib/serve/stream.js +0 -116
- package/dist/lib/serve/token.d.ts +0 -35
- package/dist/lib/serve/token.js +0 -85
- package/dist/lib/tickets/list.d.ts +0 -53
- package/dist/lib/tickets/list.js +0 -153
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-machine fan-out for the default `agents sessions` listing.
|
|
3
|
+
*
|
|
4
|
+
* `discoverSessions()` only scans the local disk. To browse the whole fleet in
|
|
5
|
+
* one list — without syncing anything — we run `agents sessions <same query>
|
|
6
|
+
* --json` on each peer over SSH and merge the parsed `SessionMeta[]`, tagging
|
|
7
|
+
* every row with the machine it came from so the picker/table can label and
|
|
8
|
+
* group by computer.
|
|
9
|
+
*
|
|
10
|
+
* This is the browse-listing sibling of `remote-active.ts` (which fans out
|
|
11
|
+
* `--active`): same transport, same device set, same recursion guard. The peer
|
|
12
|
+
* runs with `AGENTS_SESSIONS_LOCAL=1` so it answers only for itself and the
|
|
13
|
+
* sweep never recurses. A dead or slow host is skipped with a stderr note,
|
|
14
|
+
* never fatal — one asleep laptop must not blank the list.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from 'child_process';
|
|
17
|
+
import chalk from 'chalk';
|
|
18
|
+
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote, REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator, } from '../../ssh-exec.js';
|
|
19
|
+
import { sshTargetFor } from '../../devices/connect.js';
|
|
20
|
+
import { resolveExplicitTargetSet } from '../../devices/resolve-target.js';
|
|
21
|
+
import { loadDevices, isDialableDevice } from '../../devices/registry.js';
|
|
22
|
+
import { remoteShellFor, buildWindowsAgentsCommand, stripClixml } from '../../hosts/remote-cmd.js';
|
|
23
|
+
import { gatherRemoteAgentsJson } from '../../remote-agents-json.js';
|
|
24
|
+
import { machineId, normalizeHost } from '../sync/config.js';
|
|
25
|
+
import { NO_FANOUT_ENV } from '../remote-active.js';
|
|
26
|
+
import { terminalWidth } from '../../text/width.js';
|
|
27
|
+
import { sanitizeForTerminal } from '../../redact.js';
|
|
28
|
+
import { mapBounded } from '../../concurrency.js';
|
|
29
|
+
import { TOOL_QUERY_MAX_CLAUSE_BYTES, TOOL_QUERY_MAX_CALL_ROWS, TOOL_QUERY_MAX_CLAUSES, TOOL_QUERY_MAX_RESULT_SESSIONS, TOOL_QUERY_MAX_SERIALIZED_BYTES, serializedToolSearchEnvelopeBytes, } from '../tool-index.js';
|
|
30
|
+
import { TOOL_ERROR_OUTPUT_MAX_BYTES, TOOL_INPUT_MAX_BYTES, TOOL_SUCCESS_OUTPUT_MAX_BYTES, sanitizeToolEvidenceText, } from '../tool-calls.js';
|
|
31
|
+
const REMOTE_TOOL_TIMEOUT_MS = 60_000;
|
|
32
|
+
// The per-peer stdout ceiling and the UTF-8-safe accumulator live in ssh-exec.ts
|
|
33
|
+
// (the shared SSH transport both this reader and the top-level `remote-agents-json`
|
|
34
|
+
// fan-out import), so the bound is defined once. Re-exported here for the existing
|
|
35
|
+
// consumers/tests that reach them through this module.
|
|
36
|
+
export { REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator } from '../../ssh-exec.js';
|
|
37
|
+
export const REMOTE_TOOL_AGGREGATE_MAX_BYTES = TOOL_QUERY_MAX_SERIALIZED_BYTES;
|
|
38
|
+
/** Claim received bytes against one fleet-query budget before retaining them. */
|
|
39
|
+
export function consumeRemoteToolByteBudget(budget, bytes) {
|
|
40
|
+
if (budget.exhausted || bytes > budget.remainingBytes) {
|
|
41
|
+
budget.remainingBytes = 0;
|
|
42
|
+
budget.exhausted = true;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
budget.remainingBytes -= bytes;
|
|
46
|
+
if (budget.remainingBytes === 0)
|
|
47
|
+
budget.exhausted = true;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
/** Charge sanitized, machine-stamped evidence because redaction may expand it. */
|
|
51
|
+
export function consumeParsedRemoteToolSearchBudget(budget, envelope) {
|
|
52
|
+
return consumeRemoteToolByteBudget(budget, serializedToolSearchEnvelopeBytes(envelope));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The command run on each peer: answer for itself, as JSON, without recursing.
|
|
56
|
+
* `forwardedArgs` carry the caller's own query/filters (already including the
|
|
57
|
+
* leading `sessions` and a `--json`) so each peer returns a comparable slice.
|
|
58
|
+
* A Windows peer gets a PowerShell invocation (ssh lands in cmd.exe/PowerShell
|
|
59
|
+
* there, where `bash -lc` is not a command); every other OS keeps `bash -lc`.
|
|
60
|
+
*/
|
|
61
|
+
export function remoteListCommand(forwardedArgs, os) {
|
|
62
|
+
if (remoteShellFor(os) === 'powershell') {
|
|
63
|
+
return buildWindowsAgentsCommand({
|
|
64
|
+
args: forwardedArgs,
|
|
65
|
+
env: { [NO_FANOUT_ENV]: '1' },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const inner = [`${NO_FANOUT_ENV}=1`, 'agents', ...forwardedArgs].map((t, i) => i === 0 ? t : shellQuote(t)).join(' ');
|
|
69
|
+
return `bash -lc ${shellQuote(inner)}`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Parse a peer's `sessions --json` stdout into `SessionMeta[]`, tagging each
|
|
73
|
+
* with `machine`. Defensive against version skew / partial output: non-JSON or
|
|
74
|
+
* a non-array yields `[]`, and non-object entries are dropped rather than
|
|
75
|
+
* throwing. The `machine` we dialed always wins over any value the peer set on
|
|
76
|
+
* its own rows, so grouping keys off the computer we asked. Exported for unit
|
|
77
|
+
* testing without a live tailnet.
|
|
78
|
+
*/
|
|
79
|
+
export function parseRemoteList(stdout, machine) {
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(stripClixml(stdout));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(parsed))
|
|
88
|
+
return [];
|
|
89
|
+
return parsed.flatMap((value) => value && typeof value === 'object' && !Array.isArray(value)
|
|
90
|
+
? [{ ...value, machine, _remote: true }]
|
|
91
|
+
: []);
|
|
92
|
+
}
|
|
93
|
+
/** Strict parser used at the live peer boundary. A successful process with
|
|
94
|
+
* malformed/non-array JSON is an incomplete source, not an empty machine. */
|
|
95
|
+
const SAFE_RESOLVER_KEYS = new Set([
|
|
96
|
+
'id', 'shortId', 'agent', 'origin', 'timestamp', 'lastActivity', 'project',
|
|
97
|
+
'version', 'label', 'topic', 'machine',
|
|
98
|
+
]);
|
|
99
|
+
function isSafeResolverRow(value) {
|
|
100
|
+
if (typeof value.id !== 'string' || typeof value.shortId !== 'string'
|
|
101
|
+
|| typeof value.agent !== 'string' || typeof value.timestamp !== 'string')
|
|
102
|
+
return false;
|
|
103
|
+
return Object.keys(value).every(key => SAFE_RESOLVER_KEYS.has(key));
|
|
104
|
+
}
|
|
105
|
+
export function parseRemoteListPayload(stdout, machine, safeResolver = false) {
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(stripClixml(stdout));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return { items: [], valid: false };
|
|
112
|
+
}
|
|
113
|
+
if (!Array.isArray(parsed))
|
|
114
|
+
return { items: [], valid: false };
|
|
115
|
+
const out = [];
|
|
116
|
+
for (const x of parsed) {
|
|
117
|
+
if (!x || typeof x !== 'object' || Array.isArray(x))
|
|
118
|
+
return { items: [], valid: false };
|
|
119
|
+
if (safeResolver && !isSafeResolverRow(x)) {
|
|
120
|
+
return { items: [], valid: false };
|
|
121
|
+
}
|
|
122
|
+
// `_remote` marks these as living on the peer's disk (not a local mirror),
|
|
123
|
+
// so the picker routes read/resume back over SSH instead of the local FS.
|
|
124
|
+
out.push({ ...x, machine, _remote: true });
|
|
125
|
+
}
|
|
126
|
+
return { items: out, valid: true };
|
|
127
|
+
}
|
|
128
|
+
/** Run one remote `agents sessions … --json` and capture stdout. Resolves
|
|
129
|
+
* `{ code: null }` on spawn error or timeout (host treated as dead). */
|
|
130
|
+
export function sshCapture(target, remoteCmd, timeoutMs, aggregateBudget, options = {}) {
|
|
131
|
+
assertValidSshTarget(target);
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
const args = [
|
|
134
|
+
...(options.hostKeyOpts ?? []),
|
|
135
|
+
...SSH_OPTS,
|
|
136
|
+
...(options.multiplex === false ? [] : controlOpts()),
|
|
137
|
+
...(options.port === undefined ? [] : ['-p', String(options.port)]),
|
|
138
|
+
target,
|
|
139
|
+
remoteCmd,
|
|
140
|
+
];
|
|
141
|
+
const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
142
|
+
const decoded = new RemoteUtf8Accumulator();
|
|
143
|
+
let stdoutBytes = 0;
|
|
144
|
+
let aggregateBudgetExceeded = false;
|
|
145
|
+
let settled = false;
|
|
146
|
+
const done = (code) => {
|
|
147
|
+
if (settled)
|
|
148
|
+
return;
|
|
149
|
+
settled = true;
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
const stdout = code === null ? decoded.current() : decoded.end();
|
|
152
|
+
resolve({ code, stdout, aggregateBudgetExceeded: aggregateBudgetExceeded || undefined });
|
|
153
|
+
};
|
|
154
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); done(null); }, timeoutMs);
|
|
155
|
+
child.stdout.on('data', (d) => {
|
|
156
|
+
if (stdoutBytes + d.byteLength > REMOTE_STDOUT_MAX_BYTES) {
|
|
157
|
+
child.kill('SIGKILL');
|
|
158
|
+
done(null);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (aggregateBudget && !consumeRemoteToolByteBudget(aggregateBudget, d.byteLength)) {
|
|
162
|
+
aggregateBudgetExceeded = true;
|
|
163
|
+
child.kill('SIGKILL');
|
|
164
|
+
done(null);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
stdoutBytes += d.byteLength;
|
|
168
|
+
decoded.write(d);
|
|
169
|
+
});
|
|
170
|
+
child.on('error', () => done(null));
|
|
171
|
+
child.on('close', (code) => done(code));
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/** Keep browse and tool-search fan-out on the same automatic peer set. */
|
|
175
|
+
export function isAutomaticSessionPeer(d, self) {
|
|
176
|
+
if (!isDialableDevice(d))
|
|
177
|
+
return false;
|
|
178
|
+
if (normalizeHost(d.name) === self)
|
|
179
|
+
return false;
|
|
180
|
+
return d.platform === 'windows' || d.platform === 'linux' || d.platform === 'macos';
|
|
181
|
+
}
|
|
182
|
+
export async function gatherRemoteList(forwardedArgs, hosts, opts) {
|
|
183
|
+
const safeResolver = forwardedArgs.includes('--resolve-safe-v1');
|
|
184
|
+
const result = await gatherRemoteAgentsJson({
|
|
185
|
+
args: forwardedArgs,
|
|
186
|
+
noFanoutEnv: NO_FANOUT_ENV,
|
|
187
|
+
hosts,
|
|
188
|
+
timeoutMs: opts?.timeoutMs,
|
|
189
|
+
earlyExit: opts?.isDefinitive ? { isDefinitive: opts.isDefinitive } : undefined,
|
|
190
|
+
parse: (stdout, machine) => parseRemoteListPayload(stdout, machine, safeResolver),
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
sessions: result.items,
|
|
194
|
+
deviceCount: result.deviceCount,
|
|
195
|
+
unreachable: [
|
|
196
|
+
...(result.discoveryFailed ? ['device registry'] : []),
|
|
197
|
+
...result.skipped,
|
|
198
|
+
...result.parseFailed,
|
|
199
|
+
],
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
export function parseRemoteToolProgramCount(stdout, machine, expectedProgram) {
|
|
203
|
+
try {
|
|
204
|
+
const parsed = JSON.parse(stdout);
|
|
205
|
+
const query = parsed.query;
|
|
206
|
+
const coverage = parsed.coverage;
|
|
207
|
+
const totals = parsed.totals;
|
|
208
|
+
const machines = parsed.machines;
|
|
209
|
+
const coverageKeys = ['indexedFiles', 'indexedCalls', 'skippedFiles', 'limitedFiles', 'remainingFiles'];
|
|
210
|
+
const totalKeys = ['occurrences', 'toolCalls', 'sessions'];
|
|
211
|
+
if (parsed.schemaVersion !== 1 || parsed.kind !== 'tool-program-count'
|
|
212
|
+
|| !query || query.program !== expectedProgram || query.semantics !== 'static-program-occurrences-v1'
|
|
213
|
+
|| !coverage || typeof coverage.complete !== 'boolean'
|
|
214
|
+
|| coverageKeys.some((key) => !Number.isSafeInteger(coverage[key]) || coverage[key] < 0)
|
|
215
|
+
|| !totals || totalKeys.some((key) => !Number.isSafeInteger(totals[key]) || totals[key] < 0)
|
|
216
|
+
|| !Array.isArray(machines) || machines.length !== 1
|
|
217
|
+
|| boundedRemoteString(parsed.generatedAt, 128) === undefined) {
|
|
218
|
+
return { items: [], valid: false };
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
valid: true,
|
|
222
|
+
items: [{
|
|
223
|
+
machine,
|
|
224
|
+
envelope: {
|
|
225
|
+
schemaVersion: 1,
|
|
226
|
+
kind: 'tool-program-count',
|
|
227
|
+
generatedAt: parsed.generatedAt,
|
|
228
|
+
query: { program: expectedProgram, semantics: 'static-program-occurrences-v1' },
|
|
229
|
+
coverage: coverage,
|
|
230
|
+
totals: totals,
|
|
231
|
+
machines: [{
|
|
232
|
+
machine,
|
|
233
|
+
coverage: coverage,
|
|
234
|
+
totals: totals,
|
|
235
|
+
}],
|
|
236
|
+
},
|
|
237
|
+
}],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return { items: [], valid: false };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
export async function gatherRemoteToolProgramCounts(forwardedArgs, hosts, expectedProgram) {
|
|
245
|
+
const result = await gatherRemoteAgentsJson({
|
|
246
|
+
args: forwardedArgs,
|
|
247
|
+
noFanoutEnv: NO_FANOUT_ENV,
|
|
248
|
+
hosts,
|
|
249
|
+
timeoutMs: REMOTE_TOOL_TIMEOUT_MS,
|
|
250
|
+
parse: (stdout, machine) => parseRemoteToolProgramCount(stdout, machine, expectedProgram),
|
|
251
|
+
});
|
|
252
|
+
return {
|
|
253
|
+
envelopes: result.items,
|
|
254
|
+
deviceCount: result.deviceCount,
|
|
255
|
+
unreachable: [
|
|
256
|
+
...(result.discoveryFailed ? ['device registry'] : []),
|
|
257
|
+
...result.skipped,
|
|
258
|
+
...result.parseFailed,
|
|
259
|
+
],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function boundedRemoteString(value, maxBytes) {
|
|
263
|
+
if (typeof value !== 'string' || Buffer.byteLength(value) > maxBytes)
|
|
264
|
+
return undefined;
|
|
265
|
+
return sanitizeToolEvidenceText(value, maxBytes);
|
|
266
|
+
}
|
|
267
|
+
function optionalRemoteString(value, maxBytes) {
|
|
268
|
+
if (value === undefined || value === null)
|
|
269
|
+
return undefined;
|
|
270
|
+
return boundedRemoteString(value, maxBytes) ?? null;
|
|
271
|
+
}
|
|
272
|
+
function parseRemoteCall(value) {
|
|
273
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
274
|
+
return undefined;
|
|
275
|
+
const call = value;
|
|
276
|
+
const id = boundedRemoteString(call.id, 512);
|
|
277
|
+
const timestamp = boundedRemoteString(call.timestamp, 128);
|
|
278
|
+
const tool = boundedRemoteString(call.tool, 512);
|
|
279
|
+
const input = boundedRemoteString(call.input, TOOL_INPUT_MAX_BYTES);
|
|
280
|
+
const sourceCallId = optionalRemoteString(call.sourceCallId, 512);
|
|
281
|
+
const output = optionalRemoteString(call.output, TOOL_SUCCESS_OUTPUT_MAX_BYTES);
|
|
282
|
+
const error = optionalRemoteString(call.error, TOOL_ERROR_OUTPUT_MAX_BYTES);
|
|
283
|
+
const errorCode = optionalRemoteString(call.errorCode, 512);
|
|
284
|
+
const parseError = optionalRemoteString(call.parseError, 1024);
|
|
285
|
+
if (call.programs !== undefined
|
|
286
|
+
&& (!Array.isArray(call.programs) || call.programs.length > 128))
|
|
287
|
+
return undefined;
|
|
288
|
+
const programs = Array.isArray(call.programs)
|
|
289
|
+
? call.programs.map((program) => boundedRemoteString(program, 512))
|
|
290
|
+
: [];
|
|
291
|
+
if (!Array.isArray(call.programOccurrences) || call.programOccurrences.length > 10_000)
|
|
292
|
+
return undefined;
|
|
293
|
+
const programOccurrences = call.programOccurrences.map((value) => {
|
|
294
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
295
|
+
return undefined;
|
|
296
|
+
const occurrence = value;
|
|
297
|
+
const program = boundedRemoteString(occurrence.program, 512);
|
|
298
|
+
if (!program || (occurrence.role !== 'wrapper' && occurrence.role !== 'effective'))
|
|
299
|
+
return undefined;
|
|
300
|
+
return { program, role: occurrence.role };
|
|
301
|
+
});
|
|
302
|
+
if (!id || !timestamp || !tool || input === undefined
|
|
303
|
+
|| !Number.isSafeInteger(call.ordinal) || call.ordinal < 0
|
|
304
|
+
|| !['ok', 'error', 'unknown'].includes(String(call.outcome))
|
|
305
|
+
|| sourceCallId === null || output === null || error === null || errorCode === null || parseError === null
|
|
306
|
+
|| programs.some((program) => program === undefined)
|
|
307
|
+
|| programOccurrences.some((occurrence) => occurrence === undefined))
|
|
308
|
+
return undefined;
|
|
309
|
+
for (const code of [call.exitCode, call.statusCode]) {
|
|
310
|
+
if (code !== undefined && (!Number.isSafeInteger(code) || code < 0))
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
id,
|
|
315
|
+
ordinal: call.ordinal,
|
|
316
|
+
sourceCallId,
|
|
317
|
+
timestamp,
|
|
318
|
+
tool,
|
|
319
|
+
programs: programs,
|
|
320
|
+
programOccurrences: programOccurrences,
|
|
321
|
+
input,
|
|
322
|
+
outcome: call.outcome,
|
|
323
|
+
exitCode: call.exitCode,
|
|
324
|
+
statusCode: call.statusCode,
|
|
325
|
+
errorCode,
|
|
326
|
+
output,
|
|
327
|
+
error,
|
|
328
|
+
parseError,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
function parseRemoteToolSession(value, machine) {
|
|
332
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
333
|
+
return undefined;
|
|
334
|
+
const session = value;
|
|
335
|
+
const id = boundedRemoteString(session.id, 512);
|
|
336
|
+
const shortId = boundedRemoteString(session.shortId, 128);
|
|
337
|
+
const agent = boundedRemoteString(session.agent, 128);
|
|
338
|
+
const timestamp = boundedRemoteString(session.timestamp, 128);
|
|
339
|
+
const project = optionalRemoteString(session.project, 4096);
|
|
340
|
+
const cwd = optionalRemoteString(session.cwd, 4096);
|
|
341
|
+
const topic = optionalRemoteString(session.topic, 4096);
|
|
342
|
+
const label = optionalRemoteString(session.label, 4096);
|
|
343
|
+
const originMachine = optionalRemoteString(session.machine, 512);
|
|
344
|
+
const dialedMachine = boundedRemoteString(machine, 512);
|
|
345
|
+
if (!id || !shortId || !agent || !timestamp || !dialedMachine || originMachine === null
|
|
346
|
+
|| project === null || cwd === null || topic === null || label === null
|
|
347
|
+
|| !Array.isArray(session.calls) || session.calls.length > TOOL_QUERY_MAX_CALL_ROWS)
|
|
348
|
+
return undefined;
|
|
349
|
+
const calls = session.calls.map(parseRemoteCall);
|
|
350
|
+
if (calls.some((call) => call === undefined))
|
|
351
|
+
return undefined;
|
|
352
|
+
return {
|
|
353
|
+
id,
|
|
354
|
+
shortId,
|
|
355
|
+
agent,
|
|
356
|
+
machine: originMachine ?? dialedMachine,
|
|
357
|
+
timestamp,
|
|
358
|
+
project,
|
|
359
|
+
cwd,
|
|
360
|
+
topic,
|
|
361
|
+
label,
|
|
362
|
+
calls: calls,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
export function parseRemoteToolSearch(stdout, machine, expectedClauses) {
|
|
366
|
+
// The fleet tool-search fan-out reads a raw sshCapture (not the stripped
|
|
367
|
+
// gatherRemoteAgentsJson wrapper), so a Windows peer's PowerShell CLIXML banner
|
|
368
|
+
// must be removed here too or the box reads as "no envelope" (RUSH-2286).
|
|
369
|
+
stdout = stripClixml(stdout);
|
|
370
|
+
if (Buffer.byteLength(stdout) > REMOTE_STDOUT_MAX_BYTES)
|
|
371
|
+
return undefined;
|
|
372
|
+
try {
|
|
373
|
+
const parsed = JSON.parse(stdout);
|
|
374
|
+
const coverage = parsed?.coverage;
|
|
375
|
+
const query = parsed?.query;
|
|
376
|
+
if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed.sessions)
|
|
377
|
+
|| parsed.sessions.length > TOOL_QUERY_MAX_RESULT_SESSIONS || !coverage || !query
|
|
378
|
+
|| !Array.isArray(query.clauses) || query.clauses.length > TOOL_QUERY_MAX_CLAUSES
|
|
379
|
+
|| query.clauses.some((clause) => boundedRemoteString(clause, TOOL_QUERY_MAX_CLAUSE_BYTES) === undefined))
|
|
380
|
+
return undefined;
|
|
381
|
+
let totalCalls = 0;
|
|
382
|
+
for (const sessionValue of parsed.sessions) {
|
|
383
|
+
if (!sessionValue || typeof sessionValue !== 'object' || Array.isArray(sessionValue))
|
|
384
|
+
return undefined;
|
|
385
|
+
const calls = sessionValue.calls;
|
|
386
|
+
if (!Array.isArray(calls) || calls.length > TOOL_QUERY_MAX_CALL_ROWS)
|
|
387
|
+
return undefined;
|
|
388
|
+
totalCalls += calls.length;
|
|
389
|
+
if (totalCalls > TOOL_QUERY_MAX_CALL_ROWS)
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
const clauses = query.clauses.map((clause) => sanitizeForTerminal(clause));
|
|
393
|
+
const expected = expectedClauses?.map((clause) => sanitizeForTerminal(clause));
|
|
394
|
+
if (expected && (clauses.length !== expected.length
|
|
395
|
+
|| clauses.some((clause, index) => clause !== expected[index])))
|
|
396
|
+
return undefined;
|
|
397
|
+
const coverageNumbers = ['indexedFiles', 'indexedCalls', 'skippedFiles', 'limitedFiles', 'remainingFiles'];
|
|
398
|
+
if (coverageNumbers.some((key) => !Number.isSafeInteger(coverage[key]) || coverage[key] < 0)
|
|
399
|
+
|| typeof coverage.complete !== 'boolean')
|
|
400
|
+
return undefined;
|
|
401
|
+
const sessions = parsed.sessions.map((session) => parseRemoteToolSession(session, machine));
|
|
402
|
+
const generatedAt = boundedRemoteString(parsed.generatedAt, 128);
|
|
403
|
+
if (!generatedAt || sessions.some((session) => session === undefined))
|
|
404
|
+
return undefined;
|
|
405
|
+
return {
|
|
406
|
+
schemaVersion: 1,
|
|
407
|
+
generatedAt,
|
|
408
|
+
query: { clauses },
|
|
409
|
+
coverage: {
|
|
410
|
+
indexedFiles: coverage.indexedFiles,
|
|
411
|
+
indexedCalls: coverage.indexedCalls,
|
|
412
|
+
skippedFiles: coverage.skippedFiles,
|
|
413
|
+
limitedFiles: coverage.limitedFiles,
|
|
414
|
+
remainingFiles: coverage.remainingFiles,
|
|
415
|
+
complete: coverage.complete,
|
|
416
|
+
},
|
|
417
|
+
sessions: sessions,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
return undefined;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Fleet sibling of {@link gatherRemoteList} for the versioned tool-search
|
|
426
|
+
* envelope. Each peer executes the same local-only query against its own
|
|
427
|
+
* SQLite index; only compact matches cross SSH.
|
|
428
|
+
*/
|
|
429
|
+
export async function gatherRemoteToolSearch(forwardedArgs, hosts, maxAggregateBytes = REMOTE_TOOL_AGGREGATE_MAX_BYTES, expectedClauses = []) {
|
|
430
|
+
const self = machineId();
|
|
431
|
+
const targets = [];
|
|
432
|
+
const unresolved = [];
|
|
433
|
+
if (hosts && hosts.length > 0) {
|
|
434
|
+
const resolved = await resolveExplicitTargetSet(hosts);
|
|
435
|
+
targets.push(...resolved.targets);
|
|
436
|
+
unresolved.push(...resolved.unresolved);
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
let reg;
|
|
440
|
+
try {
|
|
441
|
+
reg = await loadDevices();
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
return { envelopes: [], deviceCount: 0, unreachable: ['device registry'], truncated: [] };
|
|
445
|
+
}
|
|
446
|
+
for (const d of Object.values(reg)) {
|
|
447
|
+
if (!isAutomaticSessionPeer(d, self))
|
|
448
|
+
continue;
|
|
449
|
+
try {
|
|
450
|
+
targets.push({ target: sshTargetFor(d), machine: normalizeHost(d.name), name: d.name, os: d.platform });
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
// A registered control record without a dialable address is not a query target.
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const remainingBytes = Math.max(0, Math.min(REMOTE_TOOL_AGGREGATE_MAX_BYTES, maxAggregateBytes));
|
|
458
|
+
const aggregateBudget = {
|
|
459
|
+
remainingBytes,
|
|
460
|
+
exhausted: remainingBytes === 0,
|
|
461
|
+
};
|
|
462
|
+
const parsedBudget = {
|
|
463
|
+
remainingBytes,
|
|
464
|
+
exhausted: remainingBytes === 0,
|
|
465
|
+
};
|
|
466
|
+
const results = await mapBounded(targets, async (target) => {
|
|
467
|
+
if (aggregateBudget.exhausted)
|
|
468
|
+
return { target, truncated: target.name };
|
|
469
|
+
const capture = await sshCapture(target.target, remoteListCommand(forwardedArgs, target.os), REMOTE_TOOL_TIMEOUT_MS, aggregateBudget,
|
|
470
|
+
// A tool-index deadline must own the SSH connection. Killing a
|
|
471
|
+
// multiplexed child would leave its remote command running on the
|
|
472
|
+
// persistent control master.
|
|
473
|
+
{ multiplex: false });
|
|
474
|
+
if (capture.aggregateBudgetExceeded)
|
|
475
|
+
return { target, truncated: target.name };
|
|
476
|
+
if (capture.code !== 0)
|
|
477
|
+
return { target, unreachable: target.name };
|
|
478
|
+
const envelope = parseRemoteToolSearch(capture.stdout, target.machine, expectedClauses);
|
|
479
|
+
if (!envelope)
|
|
480
|
+
return { target, unreachable: target.name };
|
|
481
|
+
if (!consumeParsedRemoteToolSearchBudget(parsedBudget, envelope)) {
|
|
482
|
+
return { target, truncated: target.name };
|
|
483
|
+
}
|
|
484
|
+
return { target, envelope };
|
|
485
|
+
}, { concurrency: 6 });
|
|
486
|
+
for (const result of results) {
|
|
487
|
+
if (result.unreachable) {
|
|
488
|
+
process.stderr.write(chalk.gray(` ${result.unreachable}: unreachable, incompatible, or no agents CLI — skipped\n`));
|
|
489
|
+
}
|
|
490
|
+
if (result.truncated) {
|
|
491
|
+
process.stderr.write(chalk.gray(` ${result.truncated}: fleet tool-result budget exhausted — skipped\n`));
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return {
|
|
495
|
+
envelopes: results.flatMap((result) => result.envelope
|
|
496
|
+
? [{ machine: result.target.machine, envelope: result.envelope }]
|
|
497
|
+
: []),
|
|
498
|
+
deviceCount: targets.length,
|
|
499
|
+
unreachable: [...unresolved, ...results.map((result) => result.unreachable).filter((name) => !!name)],
|
|
500
|
+
truncated: results.map((result) => result.truncated).filter((name) => !!name),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
/** Resolve a peer's SSH target (and OS) from the device registry by its
|
|
504
|
+
* normalized machine id — the same id the fan-out tags rows with. Returns
|
|
505
|
+
* undefined when no registered device with an address matches. */
|
|
506
|
+
export async function resolvePeerTarget(machine) {
|
|
507
|
+
let reg;
|
|
508
|
+
try {
|
|
509
|
+
reg = await loadDevices();
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
for (const d of Object.values(reg)) {
|
|
515
|
+
if (normalizeHost(d.name) !== machine)
|
|
516
|
+
continue;
|
|
517
|
+
try {
|
|
518
|
+
return { target: sshTargetFor(d), os: d.platform };
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return undefined; // matched the machine, but it has no address to dial
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return undefined;
|
|
525
|
+
}
|
|
526
|
+
/** Interactive ceiling for a picker-driven peer preview fetch. Tighter than the
|
|
527
|
+
* fan-out's 60s: the user is arrowing through rows, and a peer that can't
|
|
528
|
+
* answer a one-session digest in this window should degrade to the metadata
|
|
529
|
+
* card rather than hold a "fetching…" pane open. */
|
|
530
|
+
const PEER_PREVIEW_TIMEOUT_MS = 15_000;
|
|
531
|
+
/**
|
|
532
|
+
* Fetch one remote session's preview digest from its owning peer — the data
|
|
533
|
+
* behind the picker pane for a `_remote` row, whose transcript file this
|
|
534
|
+
* machine cannot parse. Runs the peer's own `agents sessions preview <id>
|
|
535
|
+
* --local --json` (the same envelope `agents sessions preview` already
|
|
536
|
+
* delegates to for a remote id) and returns its `preview` object, verbatim and
|
|
537
|
+
* UNSANITIZED — the caller owns scrubbing peer-supplied strings before any of
|
|
538
|
+
* them reach a TTY. Undefined on every failure: unregistered machine, SSH
|
|
539
|
+
* error, timeout, version-skewed peer with no `--json` preview envelope.
|
|
540
|
+
*/
|
|
541
|
+
export async function fetchPeerPreviewDigest(sessionId, machine, timeoutMs = PEER_PREVIEW_TIMEOUT_MS) {
|
|
542
|
+
const peer = await resolvePeerTarget(machine);
|
|
543
|
+
if (!peer)
|
|
544
|
+
return undefined;
|
|
545
|
+
const cmd = remoteListCommand(['sessions', 'preview', sessionId, '--local', '--json'], peer.os);
|
|
546
|
+
const capture = await sshCapture(peer.target, cmd, timeoutMs);
|
|
547
|
+
if (capture.code !== 0)
|
|
548
|
+
return undefined;
|
|
549
|
+
let parsed;
|
|
550
|
+
try {
|
|
551
|
+
parsed = JSON.parse(stripClixml(capture.stdout));
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
return undefined;
|
|
555
|
+
}
|
|
556
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
557
|
+
return undefined;
|
|
558
|
+
const preview = parsed.preview;
|
|
559
|
+
if (!preview || typeof preview !== 'object' || Array.isArray(preview))
|
|
560
|
+
return undefined;
|
|
561
|
+
return preview;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Run `agents <args>` ON a peer over SSH, attached to this terminal (inherited
|
|
565
|
+
* stdio). `args` is the full arg vector after the binary — callers pass e.g.
|
|
566
|
+
* `['sessions', id, '--markdown']` or `['sessions', 'resume', id]`. Used when a
|
|
567
|
+
* picked session lives on another machine: its transcript and agent binary are
|
|
568
|
+
* there, so both reading (no TTY) and resuming (TTY) must execute on the peer —
|
|
569
|
+
* not via a local `--device` hop, which would discover locally and dead-end for a
|
|
570
|
+
* session that exists only on the peer. Resolves 'no-target' when the machine
|
|
571
|
+
* isn't a dialable registered device; the caller surfaces a clear message.
|
|
572
|
+
*
|
|
573
|
+
* `opts.env` adds variables to the remote command. It deliberately does NOT
|
|
574
|
+
* carry `AGENTS_FLEET_REMOTE` the way the `--device` passthrough does: that marker
|
|
575
|
+
* gates consent-sensitive actions on the far side
|
|
576
|
+
* (lib/browser/remote-control.ts), and a resumed agent is a long-lived session
|
|
577
|
+
* that would inherit it for its whole life — `agents browser start` inside it
|
|
578
|
+
* would then be refused as a cross-machine drive. A one-shot `--device` command
|
|
579
|
+
* can carry the marker; a session cannot.
|
|
580
|
+
*/
|
|
581
|
+
export async function runOnPeer(args, machine, opts = {}) {
|
|
582
|
+
const peer = await resolvePeerTarget(machine);
|
|
583
|
+
if (!peer)
|
|
584
|
+
return 'no-target';
|
|
585
|
+
assertValidSshTarget(peer.target); // registry-sourced, but validate like the fan-out does
|
|
586
|
+
const cols = terminalWidth();
|
|
587
|
+
const env = { ...(cols > 0 ? { COLUMNS: String(cols) } : {}), ...opts.env };
|
|
588
|
+
const assignments = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`);
|
|
589
|
+
const remoteCmd = remoteShellFor(peer.os) === 'powershell'
|
|
590
|
+
? buildWindowsAgentsCommand({ args, env: assignments.length ? env : undefined })
|
|
591
|
+
: `bash -lc ${shellQuote(assignments.concat(['agents', ...args].map(shellQuote)).join(' '))}`;
|
|
592
|
+
const sshArgs = [...SSH_OPTS, ...controlOpts()];
|
|
593
|
+
if (opts.tty)
|
|
594
|
+
sshArgs.push('-tt'); // force a PTY so the resumed agent is interactive
|
|
595
|
+
sshArgs.push(peer.target, remoteCmd);
|
|
596
|
+
return new Promise((resolve) => {
|
|
597
|
+
const child = spawn('ssh', sshArgs, { stdio: 'inherit' });
|
|
598
|
+
// ssh prints its own connection errors to the inherited stderr; a spawn
|
|
599
|
+
// failure (e.g. ssh not on PATH) has no such output, so name it. Either way
|
|
600
|
+
// we resolve once it settles so the picker flow completes.
|
|
601
|
+
child.on('error', (err) => {
|
|
602
|
+
process.stderr.write(chalk.red(`Failed to reach ${machine}: ${err?.message ?? 'ssh failed to launch'}\n`));
|
|
603
|
+
resolve('ok');
|
|
604
|
+
});
|
|
605
|
+
child.on('close', () => resolve('ok'));
|
|
606
|
+
});
|
|
607
|
+
}
|