@mgsoftwarebv/mg-dashboard-mcp 3.10.2 → 3.11.1
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/dist/index.d.ts +20 -1
- package/dist/index.js +1164 -161
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ import { createClient } from '@supabase/supabase-js';
|
|
|
14
14
|
import { readFile, mkdtemp, writeFile, rm } from 'fs/promises';
|
|
15
15
|
import { tmpdir } from 'os';
|
|
16
16
|
import { Client } from 'ssh2';
|
|
17
|
-
import { ListObjectsV2Command, DeleteObjectsCommand, DeleteObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, PutObjectCommand,
|
|
17
|
+
import { HeadObjectCommand, S3Client, ListObjectsV2Command, DeleteObjectsCommand, DeleteObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, PutObjectCommand, GetObjectCommand, CopyObjectCommand } from '@aws-sdk/client-s3';
|
|
18
18
|
|
|
19
19
|
var __defProp = Object.defineProperty;
|
|
20
20
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
@@ -1373,6 +1373,7 @@ var TOOL_MODULE_MAP = {
|
|
|
1373
1373
|
"docker-logs": "ssh_servers",
|
|
1374
1374
|
"docker-exec": "ssh_servers",
|
|
1375
1375
|
"docker-compose": "ssh_servers",
|
|
1376
|
+
"wait-for": "ssh_servers",
|
|
1376
1377
|
"db-discover": "ssh_servers",
|
|
1377
1378
|
"db-tables": "ssh_servers",
|
|
1378
1379
|
"db-describe": "ssh_servers",
|
|
@@ -1861,7 +1862,12 @@ async function getProxyConnection() {
|
|
|
1861
1862
|
async function getServerConnection(serverId) {
|
|
1862
1863
|
assertServerAccess(serverId);
|
|
1863
1864
|
const { data, error } = await supabase.from("ssh_server").select("hostname, port, username, password_encrypted, ssh_key_encrypted, ssh_key_passphrase_encrypted, allowed_ssh_ips, os_type").eq("id", serverId).single();
|
|
1864
|
-
if (error || !data)
|
|
1865
|
+
if (error || !data) {
|
|
1866
|
+
const candidates = Array.from(KNOWN_SERVER_NAMES.values()).flatMap((s) => [s.name, s.id]);
|
|
1867
|
+
const hits = suggestSimilar(serverId, candidates);
|
|
1868
|
+
const hint = hits.length ? ` Did you mean: ${hits.join(", ")}?` : "";
|
|
1869
|
+
throw new Error(`Server not found: ${serverId}.${hint}`);
|
|
1870
|
+
}
|
|
1865
1871
|
if (!encryptionKey) throw new Error("ENCRYPTION_KEY required to decrypt server credentials");
|
|
1866
1872
|
const conn = {
|
|
1867
1873
|
hostname: data.hostname,
|
|
@@ -1877,42 +1883,73 @@ async function getServerConnection(serverId) {
|
|
|
1877
1883
|
return { conn, proxy, os };
|
|
1878
1884
|
}
|
|
1879
1885
|
async function sshExec(opts, command, proxy, options) {
|
|
1886
|
+
const first = await sshExecOnce(opts, command, proxy, options);
|
|
1887
|
+
if (options?.noRetry) return first;
|
|
1888
|
+
if (!isTransientSshError(first.stderr, first.exitCode)) return first;
|
|
1889
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
1890
|
+
const second = await sshExecOnce(opts, command, proxy, options);
|
|
1891
|
+
if (second.exitCode === -1 && second.stderr) {
|
|
1892
|
+
second.stderr = `[retry-1 also failed] ${second.stderr}`;
|
|
1893
|
+
}
|
|
1894
|
+
return second;
|
|
1895
|
+
}
|
|
1896
|
+
async function sshExecOnce(opts, command, proxy, options) {
|
|
1880
1897
|
if (proxy) return sshExecViaProxy(proxy, opts, command, options);
|
|
1881
1898
|
return new Promise((resolve) => {
|
|
1882
1899
|
const ssh = new Client();
|
|
1883
1900
|
let stdout = "";
|
|
1884
1901
|
let stderr = "";
|
|
1885
1902
|
let done = false;
|
|
1886
|
-
const
|
|
1887
|
-
const
|
|
1903
|
+
const wallTimeout = opts.timeout || 6e4;
|
|
1904
|
+
const idleTimeout = options?.idleTimeoutMs;
|
|
1905
|
+
const wallTimer = setTimeout(() => {
|
|
1888
1906
|
if (!done) {
|
|
1889
1907
|
done = true;
|
|
1908
|
+
clearTimeout(idleTimer);
|
|
1890
1909
|
ssh.end();
|
|
1891
|
-
resolve({ stdout, stderr
|
|
1910
|
+
resolve({ stdout, stderr: stderr || `idle/wall timeout after ${wallTimeout}ms`, exitCode: -1 });
|
|
1892
1911
|
}
|
|
1893
|
-
},
|
|
1912
|
+
}, wallTimeout);
|
|
1913
|
+
let idleTimer;
|
|
1914
|
+
const armIdle = () => {
|
|
1915
|
+
if (!idleTimeout) return;
|
|
1916
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1917
|
+
idleTimer = setTimeout(() => {
|
|
1918
|
+
if (!done) {
|
|
1919
|
+
done = true;
|
|
1920
|
+
clearTimeout(wallTimer);
|
|
1921
|
+
ssh.end();
|
|
1922
|
+
resolve({ stdout, stderr: stderr || `idle timeout after ${idleTimeout}ms with no output`, exitCode: -1 });
|
|
1923
|
+
}
|
|
1924
|
+
}, idleTimeout);
|
|
1925
|
+
};
|
|
1926
|
+
armIdle();
|
|
1894
1927
|
ssh.on("ready", () => {
|
|
1895
1928
|
const execOpts = options?.pty ? { pty: true } : {};
|
|
1896
1929
|
ssh.exec(command, execOpts, (err, stream) => {
|
|
1897
1930
|
if (err) {
|
|
1898
1931
|
if (!done) {
|
|
1899
1932
|
done = true;
|
|
1900
|
-
clearTimeout(
|
|
1933
|
+
clearTimeout(wallTimer);
|
|
1934
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1901
1935
|
ssh.end();
|
|
1902
|
-
resolve({ stdout, stderr, exitCode: -1 });
|
|
1936
|
+
resolve({ stdout, stderr: err.message || stderr, exitCode: -1 });
|
|
1903
1937
|
}
|
|
1904
1938
|
return;
|
|
1905
1939
|
}
|
|
1906
1940
|
stream.on("data", (d) => {
|
|
1907
1941
|
stdout += d.toString();
|
|
1942
|
+
armIdle();
|
|
1908
1943
|
});
|
|
1909
1944
|
stream.stderr.on("data", (d) => {
|
|
1910
1945
|
stderr += d.toString();
|
|
1946
|
+
armIdle();
|
|
1911
1947
|
});
|
|
1912
1948
|
stream.on("close", (code) => {
|
|
1913
1949
|
if (!done) {
|
|
1914
1950
|
done = true;
|
|
1915
|
-
clearTimeout(
|
|
1951
|
+
clearTimeout(wallTimer);
|
|
1952
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1916
1953
|
ssh.end();
|
|
1917
1954
|
resolve({ stdout, stderr, exitCode: code ?? 0 });
|
|
1918
1955
|
}
|
|
@@ -1925,7 +1962,8 @@ async function sshExec(opts, command, proxy, options) {
|
|
|
1925
1962
|
ssh.on("error", (err) => {
|
|
1926
1963
|
if (!done) {
|
|
1927
1964
|
done = true;
|
|
1928
|
-
clearTimeout(
|
|
1965
|
+
clearTimeout(wallTimer);
|
|
1966
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1929
1967
|
resolve({ stdout, stderr: err.message, exitCode: -1 });
|
|
1930
1968
|
}
|
|
1931
1969
|
});
|
|
@@ -1936,7 +1974,7 @@ async function sshExec(opts, command, proxy, options) {
|
|
|
1936
1974
|
password: opts.password,
|
|
1937
1975
|
privateKey: opts.privateKey,
|
|
1938
1976
|
passphrase: opts.passphrase,
|
|
1939
|
-
readyTimeout:
|
|
1977
|
+
readyTimeout: wallTimeout
|
|
1940
1978
|
});
|
|
1941
1979
|
});
|
|
1942
1980
|
}
|
|
@@ -1944,16 +1982,35 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
|
|
|
1944
1982
|
return new Promise((resolve) => {
|
|
1945
1983
|
const proxyClient = new Client();
|
|
1946
1984
|
let done = false;
|
|
1947
|
-
const
|
|
1948
|
-
const
|
|
1985
|
+
const wallTimeout = targetOpts.timeout || 6e4;
|
|
1986
|
+
const idleTimeout = options?.idleTimeoutMs;
|
|
1987
|
+
const wallTimer = setTimeout(() => {
|
|
1949
1988
|
if (!done) {
|
|
1950
1989
|
done = true;
|
|
1990
|
+
clearTimeout(idleTimer);
|
|
1951
1991
|
proxyClient.end();
|
|
1952
1992
|
resolve({ stdout: "", stderr: "SSH proxy command timeout", exitCode: -1 });
|
|
1953
1993
|
}
|
|
1954
|
-
},
|
|
1994
|
+
}, wallTimeout);
|
|
1995
|
+
let idleTimer;
|
|
1996
|
+
let stdoutBuf = "";
|
|
1997
|
+
let stderrBuf = "";
|
|
1998
|
+
const armIdle = () => {
|
|
1999
|
+
if (!idleTimeout) return;
|
|
2000
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
2001
|
+
idleTimer = setTimeout(() => {
|
|
2002
|
+
if (!done) {
|
|
2003
|
+
done = true;
|
|
2004
|
+
clearTimeout(wallTimer);
|
|
2005
|
+
proxyClient.end();
|
|
2006
|
+
resolve({ stdout: stdoutBuf, stderr: stderrBuf || `idle timeout after ${idleTimeout}ms with no output`, exitCode: -1 });
|
|
2007
|
+
}
|
|
2008
|
+
}, idleTimeout);
|
|
2009
|
+
};
|
|
2010
|
+
armIdle();
|
|
1955
2011
|
const cleanup = () => {
|
|
1956
|
-
clearTimeout(
|
|
2012
|
+
clearTimeout(wallTimer);
|
|
2013
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1957
2014
|
proxyClient.end();
|
|
1958
2015
|
};
|
|
1959
2016
|
proxyClient.on("ready", () => {
|
|
@@ -1967,8 +2024,6 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
|
|
|
1967
2024
|
return;
|
|
1968
2025
|
}
|
|
1969
2026
|
const targetClient = new Client();
|
|
1970
|
-
let stdout = "";
|
|
1971
|
-
let stderr = "";
|
|
1972
2027
|
targetClient.on("ready", () => {
|
|
1973
2028
|
const execOpts = options?.pty ? { pty: true } : {};
|
|
1974
2029
|
targetClient.exec(command, execOpts, (execErr, stream) => {
|
|
@@ -1977,22 +2032,24 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
|
|
|
1977
2032
|
done = true;
|
|
1978
2033
|
targetClient.end();
|
|
1979
2034
|
cleanup();
|
|
1980
|
-
resolve({ stdout, stderr, exitCode: -1 });
|
|
2035
|
+
resolve({ stdout: stdoutBuf, stderr: stderrBuf, exitCode: -1 });
|
|
1981
2036
|
}
|
|
1982
2037
|
return;
|
|
1983
2038
|
}
|
|
1984
2039
|
stream.on("data", (d) => {
|
|
1985
|
-
|
|
2040
|
+
stdoutBuf += d.toString();
|
|
2041
|
+
armIdle();
|
|
1986
2042
|
});
|
|
1987
2043
|
stream.stderr.on("data", (d) => {
|
|
1988
|
-
|
|
2044
|
+
stderrBuf += d.toString();
|
|
2045
|
+
armIdle();
|
|
1989
2046
|
});
|
|
1990
2047
|
stream.on("close", (code) => {
|
|
1991
2048
|
if (!done) {
|
|
1992
2049
|
done = true;
|
|
1993
2050
|
targetClient.end();
|
|
1994
2051
|
cleanup();
|
|
1995
|
-
resolve({ stdout, stderr, exitCode: code ?? 0 });
|
|
2052
|
+
resolve({ stdout: stdoutBuf, stderr: stderrBuf, exitCode: code ?? 0 });
|
|
1996
2053
|
}
|
|
1997
2054
|
});
|
|
1998
2055
|
if (options?.stdin !== void 0) {
|
|
@@ -2005,7 +2062,7 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
|
|
|
2005
2062
|
done = true;
|
|
2006
2063
|
targetClient.end();
|
|
2007
2064
|
cleanup();
|
|
2008
|
-
resolve({ stdout, stderr: targetErr.message, exitCode: -1 });
|
|
2065
|
+
resolve({ stdout: stdoutBuf, stderr: targetErr.message, exitCode: -1 });
|
|
2009
2066
|
}
|
|
2010
2067
|
});
|
|
2011
2068
|
targetClient.connect({
|
|
@@ -2014,7 +2071,7 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
|
|
|
2014
2071
|
password: targetOpts.password,
|
|
2015
2072
|
privateKey: targetOpts.privateKey,
|
|
2016
2073
|
passphrase: targetOpts.passphrase,
|
|
2017
|
-
readyTimeout:
|
|
2074
|
+
readyTimeout: wallTimeout
|
|
2018
2075
|
});
|
|
2019
2076
|
});
|
|
2020
2077
|
});
|
|
@@ -2307,6 +2364,44 @@ async function r2PutObjectMultipart(bucket, key, localPath, totalBytes, onProgre
|
|
|
2307
2364
|
throw e;
|
|
2308
2365
|
}
|
|
2309
2366
|
}
|
|
2367
|
+
async function r2CopyObject(srcBucket, srcKey, dstBucket, dstKey) {
|
|
2368
|
+
const client = getR2Client();
|
|
2369
|
+
let size = 0;
|
|
2370
|
+
try {
|
|
2371
|
+
const head = await client.send(new HeadObjectCommand({ Bucket: srcBucket, Key: srcKey }));
|
|
2372
|
+
size = head.ContentLength || 0;
|
|
2373
|
+
} catch (e) {
|
|
2374
|
+
throw r2WrapError(srcBucket, srcKey, e);
|
|
2375
|
+
}
|
|
2376
|
+
try {
|
|
2377
|
+
await client.send(new CopyObjectCommand({
|
|
2378
|
+
CopySource: encodeURIComponent(`${srcBucket}/${srcKey}`).replace(/%2F/g, "/"),
|
|
2379
|
+
Bucket: dstBucket,
|
|
2380
|
+
Key: dstKey
|
|
2381
|
+
}));
|
|
2382
|
+
} catch (e) {
|
|
2383
|
+
throw r2WrapError(dstBucket, dstKey, e);
|
|
2384
|
+
}
|
|
2385
|
+
return { size };
|
|
2386
|
+
}
|
|
2387
|
+
async function r2GetObjectStream(bucket, key) {
|
|
2388
|
+
const client = getR2Client();
|
|
2389
|
+
let size = 0;
|
|
2390
|
+
try {
|
|
2391
|
+
const head = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
|
|
2392
|
+
size = head.ContentLength || 0;
|
|
2393
|
+
} catch (e) {
|
|
2394
|
+
throw r2WrapError(bucket, key, e);
|
|
2395
|
+
}
|
|
2396
|
+
try {
|
|
2397
|
+
const result = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
2398
|
+
const body = result.Body;
|
|
2399
|
+
if (!body) throw new Error("R2 returned no readable body");
|
|
2400
|
+
return { stream: body, size };
|
|
2401
|
+
} catch (e) {
|
|
2402
|
+
throw r2WrapError(bucket, key, e);
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2310
2405
|
function sanitizePath(path) {
|
|
2311
2406
|
let normalized = path.replace(/\\/g, "/").replace(/\0/g, "");
|
|
2312
2407
|
const parts = normalized.split("/");
|
|
@@ -2501,6 +2596,369 @@ function formatBytes(bytes) {
|
|
|
2501
2596
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
2502
2597
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
2503
2598
|
}
|
|
2599
|
+
var RESPONSE_MAX_BYTES = 8192;
|
|
2600
|
+
var NO_FOOTER_TOOLS = /* @__PURE__ */ new Set();
|
|
2601
|
+
var TOOL_CACHE_TTL_MS = {
|
|
2602
|
+
"list-servers": 6e4,
|
|
2603
|
+
"docker-list": 3e4
|
|
2604
|
+
};
|
|
2605
|
+
var TOOL_CACHE = /* @__PURE__ */ new Map();
|
|
2606
|
+
function cacheKeyFor(name, args2) {
|
|
2607
|
+
const { noCache: _ignored, ...rest } = args2;
|
|
2608
|
+
const sorted = Object.fromEntries(Object.entries(rest).sort(([a], [b]) => a.localeCompare(b)));
|
|
2609
|
+
return `${name}::${JSON.stringify(sorted)}`;
|
|
2610
|
+
}
|
|
2611
|
+
function getCachedTool(name, args2) {
|
|
2612
|
+
const ttl = TOOL_CACHE_TTL_MS[name];
|
|
2613
|
+
if (!ttl || ttl <= 0) return void 0;
|
|
2614
|
+
if (args2.noCache === true) return void 0;
|
|
2615
|
+
const k = cacheKeyFor(name, args2);
|
|
2616
|
+
const e = TOOL_CACHE.get(k);
|
|
2617
|
+
if (!e) return void 0;
|
|
2618
|
+
if (e.expiresAt < Date.now()) {
|
|
2619
|
+
TOOL_CACHE.delete(k);
|
|
2620
|
+
return void 0;
|
|
2621
|
+
}
|
|
2622
|
+
return e.value;
|
|
2623
|
+
}
|
|
2624
|
+
function setCachedTool(name, args2, value) {
|
|
2625
|
+
const ttl = TOOL_CACHE_TTL_MS[name];
|
|
2626
|
+
if (!ttl || ttl <= 0) return;
|
|
2627
|
+
if (args2.noCache === true) return;
|
|
2628
|
+
TOOL_CACHE.set(cacheKeyFor(name, args2), { value, expiresAt: Date.now() + ttl });
|
|
2629
|
+
}
|
|
2630
|
+
function levenshtein(a, b) {
|
|
2631
|
+
if (a === b) return 0;
|
|
2632
|
+
const m = a.length;
|
|
2633
|
+
const n = b.length;
|
|
2634
|
+
if (m === 0) return n;
|
|
2635
|
+
if (n === 0) return m;
|
|
2636
|
+
const dp = new Array(n + 1);
|
|
2637
|
+
for (let j = 0; j <= n; j++) dp[j] = j;
|
|
2638
|
+
for (let i = 1; i <= m; i++) {
|
|
2639
|
+
let prev = dp[0];
|
|
2640
|
+
dp[0] = i;
|
|
2641
|
+
for (let j = 1; j <= n; j++) {
|
|
2642
|
+
const tmp = dp[j];
|
|
2643
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
2644
|
+
dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + cost);
|
|
2645
|
+
prev = tmp;
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
return dp[n];
|
|
2649
|
+
}
|
|
2650
|
+
function suggestSimilar(input, candidates, max = 3) {
|
|
2651
|
+
if (!input || !candidates.length) return [];
|
|
2652
|
+
const lo = input.toLowerCase();
|
|
2653
|
+
const threshold = Math.max(2, Math.ceil(input.length / 3));
|
|
2654
|
+
return candidates.map((c) => ({ c, d: levenshtein(lo, c.toLowerCase()) })).filter((x) => x.d <= threshold).sort((a, b) => a.d - b.d).slice(0, max).map((x) => x.c);
|
|
2655
|
+
}
|
|
2656
|
+
var KNOWN_SERVER_NAMES = /* @__PURE__ */ new Map();
|
|
2657
|
+
var KNOWN_CONTAINERS_BY_SERVER = /* @__PURE__ */ new Map();
|
|
2658
|
+
function rememberServers(servers) {
|
|
2659
|
+
for (const s of servers) KNOWN_SERVER_NAMES.set(s.id, s);
|
|
2660
|
+
}
|
|
2661
|
+
function rememberContainers(serverId, names) {
|
|
2662
|
+
KNOWN_CONTAINERS_BY_SERVER.set(serverId, { names, capturedAt: Date.now() });
|
|
2663
|
+
}
|
|
2664
|
+
function buildServerContextHints(servers) {
|
|
2665
|
+
const hints = [];
|
|
2666
|
+
for (const s of servers) {
|
|
2667
|
+
const tagSet = new Set((s.tags || []).map((t) => String(t).toLowerCase()));
|
|
2668
|
+
const name = (s.name || "").toLowerCase();
|
|
2669
|
+
const host = (s.hostname || "").toLowerCase();
|
|
2670
|
+
const found = [];
|
|
2671
|
+
if (tagSet.has("trigger") || /trigger/.test(name) || /trigger/.test(host)) found.push("Trigger.dev self-hosted");
|
|
2672
|
+
if (tagSet.has("supabase") || /supabase/.test(name) || /supabase/.test(host)) found.push("Supabase stack");
|
|
2673
|
+
if (tagSet.has("proxy") || /proxy|jump/.test(name)) found.push("SSH ProxyJump host");
|
|
2674
|
+
if (tagSet.has("giga") || /giga/.test(name)) found.push("shared workloads");
|
|
2675
|
+
if (tagSet.has("vca") || /vca/.test(name)) found.push("VCA hosting");
|
|
2676
|
+
if (tagSet.has("mg-boilers") || /boiler/.test(name)) found.push("boiler API");
|
|
2677
|
+
if (tagSet.has("refront") || /refront|tickets/.test(name) || /refront|tickets/.test(host)) found.push("Refront/tickets");
|
|
2678
|
+
if (found.length) hints.push(`- ${s.name}: ${found.join(", ")}`);
|
|
2679
|
+
}
|
|
2680
|
+
return hints;
|
|
2681
|
+
}
|
|
2682
|
+
function getKnownContainers(serverId, maxAgeMs = 5 * 6e4) {
|
|
2683
|
+
const e = KNOWN_CONTAINERS_BY_SERVER.get(serverId);
|
|
2684
|
+
if (!e) return void 0;
|
|
2685
|
+
if (Date.now() - e.capturedAt > maxAgeMs) return void 0;
|
|
2686
|
+
return e.names;
|
|
2687
|
+
}
|
|
2688
|
+
function truncateForLLM(text, maxBytes) {
|
|
2689
|
+
const totalBytes = Buffer.byteLength(text, "utf8");
|
|
2690
|
+
if (totalBytes <= maxBytes) return { text, truncated: false, totalBytes, shownBytes: totalBytes };
|
|
2691
|
+
const buf = Buffer.from(text, "utf8");
|
|
2692
|
+
let cut = maxBytes;
|
|
2693
|
+
while (cut > 0 && (buf[cut] & 192) === 128) cut--;
|
|
2694
|
+
const head = buf.subarray(0, cut).toString("utf8");
|
|
2695
|
+
return { text: head, truncated: true, totalBytes, shownBytes: cut };
|
|
2696
|
+
}
|
|
2697
|
+
function buildTruncationHint(name, args2, total, shown) {
|
|
2698
|
+
const base = `(showing first ${shown} of ${total} bytes;`;
|
|
2699
|
+
if (name === "sftp-read") {
|
|
2700
|
+
const off = Number(args2.offset) || 0;
|
|
2701
|
+
const next = off + shown;
|
|
2702
|
+
return `${base} pass { offset: ${next}, length: ${RESPONSE_MAX_BYTES} } to read more)`;
|
|
2703
|
+
}
|
|
2704
|
+
if (name === "docker-logs") {
|
|
2705
|
+
return `${base} use \`tail\`, \`since\`, or \`grep\` to narrow the window)`;
|
|
2706
|
+
}
|
|
2707
|
+
if (name === "ssh-execute") {
|
|
2708
|
+
return `${base} pipe through head/tail/grep server-side, or use sftp-read with offset for huge logs)`;
|
|
2709
|
+
}
|
|
2710
|
+
return `${base} narrow your query, paginate, or grep server-side to read the rest)`;
|
|
2711
|
+
}
|
|
2712
|
+
var TRANSIENT_RE = /(ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|connection\s+refused|connection\s+reset|getaddrinfo|connect\s+ETIMEDOUT|socket\s+hang\s*up|read\s+ECONNRESET|All configured authentication methods failed)/i;
|
|
2713
|
+
function isTransientSshError(stderr, exitCode) {
|
|
2714
|
+
if (exitCode !== -1) return false;
|
|
2715
|
+
if (!stderr) return false;
|
|
2716
|
+
return TRANSIENT_RE.test(stderr);
|
|
2717
|
+
}
|
|
2718
|
+
function postprocessResult(result, meta) {
|
|
2719
|
+
if (!result.content?.length) return result;
|
|
2720
|
+
const block = result.content[0];
|
|
2721
|
+
let text = String(block.text ?? "");
|
|
2722
|
+
const trunc = truncateForLLM(text, RESPONSE_MAX_BYTES);
|
|
2723
|
+
if (trunc.truncated) {
|
|
2724
|
+
text = trunc.text + "\n\n... " + buildTruncationHint(meta.toolName, meta.args, trunc.totalBytes, trunc.shownBytes);
|
|
2725
|
+
}
|
|
2726
|
+
if (!NO_FOOTER_TOOLS.has(meta.toolName)) {
|
|
2727
|
+
const tookMs = Date.now() - meta.startedAtMs;
|
|
2728
|
+
const tookStr = tookMs < 1e3 ? `${tookMs}ms` : `${(tookMs / 1e3).toFixed(1)}s`;
|
|
2729
|
+
const sizeStr = trunc.totalBytes >= 1024 ? `${(trunc.totalBytes / 1024).toFixed(1)} KB` : `${trunc.totalBytes} B`;
|
|
2730
|
+
const parts = [`took ${tookStr}`, sizeStr];
|
|
2731
|
+
if (meta.serverIdLabel) parts.push(`server: ${meta.serverIdLabel}`);
|
|
2732
|
+
if (meta.cached) parts.push("cached");
|
|
2733
|
+
text = `${text}
|
|
2734
|
+
|
|
2735
|
+
[${parts.join(", ")}]`;
|
|
2736
|
+
}
|
|
2737
|
+
return { ...result, content: [{ ...block, text }] };
|
|
2738
|
+
}
|
|
2739
|
+
function buildPipelineScript(commands, shell, marker, stopOnError) {
|
|
2740
|
+
if (shell === "powershell") {
|
|
2741
|
+
const lines = ["$ErrorActionPreference='Continue'", "$ProgressPreference='SilentlyContinue'"];
|
|
2742
|
+
for (let i = 0; i < commands.length; i++) {
|
|
2743
|
+
lines.push(`Write-Output '${marker} ${i} begin'`);
|
|
2744
|
+
lines.push(`& { ${commands[i]} } 2>&1`);
|
|
2745
|
+
lines.push(`$__c = if ($null -eq $LASTEXITCODE) { 0 } else { $LASTEXITCODE }`);
|
|
2746
|
+
lines.push(`Write-Output ('${marker} ' + ${i} + ' end exit=' + $__c)`);
|
|
2747
|
+
if (stopOnError) lines.push(`if ($__c -ne 0) { exit $__c }`);
|
|
2748
|
+
}
|
|
2749
|
+
return buildPowerShellEncodedCommand(lines.join("; "), []);
|
|
2750
|
+
}
|
|
2751
|
+
const sh = [];
|
|
2752
|
+
for (let i = 0; i < commands.length; i++) {
|
|
2753
|
+
sh.push(`echo "${marker} ${i} begin"`);
|
|
2754
|
+
sh.push(`{ ${commands[i]}; } 2>&1`);
|
|
2755
|
+
sh.push(`__c=$?`);
|
|
2756
|
+
sh.push(`echo "${marker} ${i} end exit=$__c"`);
|
|
2757
|
+
if (stopOnError) sh.push(`if [ "$__c" -ne 0 ]; then exit "$__c"; fi`);
|
|
2758
|
+
}
|
|
2759
|
+
return `bash -c ${posixQuote(sh.join("; "))}`;
|
|
2760
|
+
}
|
|
2761
|
+
function parsePipelineOutput(stdout, commands, marker) {
|
|
2762
|
+
const segments = [];
|
|
2763
|
+
for (let i = 0; i < commands.length; i++) {
|
|
2764
|
+
const beginRe = new RegExp(`^${escapeRegex(marker)}\\s+${i}\\s+begin\\s*$`, "m");
|
|
2765
|
+
const endRe = new RegExp(`^${escapeRegex(marker)}\\s+${i}\\s+end\\s+exit=(-?\\d+)\\s*$`, "m");
|
|
2766
|
+
const beginMatch = beginRe.exec(stdout);
|
|
2767
|
+
const endMatch = endRe.exec(stdout);
|
|
2768
|
+
if (!beginMatch || !endMatch) {
|
|
2769
|
+
segments.push({ index: i, command: commands[i], output: "(no output captured \u2014 pipeline aborted before this step?)", exitCode: -1 });
|
|
2770
|
+
continue;
|
|
2771
|
+
}
|
|
2772
|
+
const start = beginMatch.index + beginMatch[0].length + 1;
|
|
2773
|
+
const end = endMatch.index;
|
|
2774
|
+
const body = stdout.slice(start, end).replace(/\n$/, "");
|
|
2775
|
+
const code = parseInt(endMatch[1], 10);
|
|
2776
|
+
segments.push({ index: i, command: commands[i], output: body, exitCode: Number.isFinite(code) ? code : -1 });
|
|
2777
|
+
}
|
|
2778
|
+
return segments;
|
|
2779
|
+
}
|
|
2780
|
+
function escapeRegex(s) {
|
|
2781
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2782
|
+
}
|
|
2783
|
+
function renderPipelineSegments(segments, overall, format) {
|
|
2784
|
+
const sections = [];
|
|
2785
|
+
for (const seg of segments) {
|
|
2786
|
+
sections.push(`>>> [${seg.index + 1}/${segments.length}] ${seg.command} (exit ${seg.exitCode})`);
|
|
2787
|
+
let body = seg.output;
|
|
2788
|
+
if (format === "ndjson") {
|
|
2789
|
+
const parsed = tryParseNdjsonFromCommand(seg.command, body);
|
|
2790
|
+
if (parsed) body = parsed;
|
|
2791
|
+
}
|
|
2792
|
+
sections.push(body || "(no output)");
|
|
2793
|
+
}
|
|
2794
|
+
if (overall.stderr) sections.push(`--- aggregate stderr ---
|
|
2795
|
+
${overall.stderr.trimEnd()}`);
|
|
2796
|
+
return sections.join("\n");
|
|
2797
|
+
}
|
|
2798
|
+
function renderRepeatResult(iters, mode, format) {
|
|
2799
|
+
const formatBody = (s) => {
|
|
2800
|
+
if (format !== "ndjson") return s;
|
|
2801
|
+
return tryParseNdjsonFromCommand("", s) || s;
|
|
2802
|
+
};
|
|
2803
|
+
if (mode === "last") {
|
|
2804
|
+
const last = iters[iters.length - 1];
|
|
2805
|
+
return `--- iteration ${last.index + 1}/${iters.length} (exit ${last.result.exitCode}, ${last.durationMs}ms) ---
|
|
2806
|
+
${formatBody(last.result.stdout) || "(empty)"}${last.result.stderr ? `
|
|
2807
|
+
[stderr] ${last.result.stderr.trimEnd()}` : ""}`;
|
|
2808
|
+
}
|
|
2809
|
+
if (mode === "diff") {
|
|
2810
|
+
const sections2 = [`(repeat: ${iters.length} runs, diff mode \u2014 only differences vs. previous run shown)`];
|
|
2811
|
+
let prev = "";
|
|
2812
|
+
for (let i = 0; i < iters.length; i++) {
|
|
2813
|
+
const cur = iters[i].result.stdout;
|
|
2814
|
+
if (i === 0) {
|
|
2815
|
+
sections2.push(`--- iteration 1 (initial, ${iters[i].durationMs}ms) ---
|
|
2816
|
+
${formatBody(cur) || "(empty)"}`);
|
|
2817
|
+
} else {
|
|
2818
|
+
const changes = diffLines(prev, cur);
|
|
2819
|
+
const header = `--- iteration ${i + 1} (${iters[i].durationMs}ms, +${changes.added.length} -${changes.removed.length}) ---`;
|
|
2820
|
+
const block = [header];
|
|
2821
|
+
if (changes.added.length === 0 && changes.removed.length === 0) {
|
|
2822
|
+
block.push("(no changes)");
|
|
2823
|
+
} else {
|
|
2824
|
+
for (const line of changes.added) block.push(`+ ${line}`);
|
|
2825
|
+
for (const line of changes.removed) block.push(`- ${line}`);
|
|
2826
|
+
}
|
|
2827
|
+
sections2.push(block.join("\n"));
|
|
2828
|
+
}
|
|
2829
|
+
prev = cur;
|
|
2830
|
+
}
|
|
2831
|
+
return sections2.join("\n");
|
|
2832
|
+
}
|
|
2833
|
+
const sections = [`(repeat: ${iters.length} runs, all output)`];
|
|
2834
|
+
for (const it of iters) {
|
|
2835
|
+
sections.push(`--- iteration ${it.index + 1}/${iters.length} (exit ${it.result.exitCode}, ${it.durationMs}ms) ---`);
|
|
2836
|
+
sections.push(formatBody(it.result.stdout) || "(empty)");
|
|
2837
|
+
if (it.result.stderr) sections.push(`[stderr] ${it.result.stderr.trimEnd()}`);
|
|
2838
|
+
}
|
|
2839
|
+
return sections.join("\n");
|
|
2840
|
+
}
|
|
2841
|
+
function diffLines(a, b) {
|
|
2842
|
+
const aSet = new Set(a.split("\n"));
|
|
2843
|
+
const bSet = new Set(b.split("\n"));
|
|
2844
|
+
const added = [];
|
|
2845
|
+
const removed = [];
|
|
2846
|
+
for (const line of bSet) if (!aSet.has(line) && line) added.push(line);
|
|
2847
|
+
for (const line of aSet) if (!bSet.has(line) && line) removed.push(line);
|
|
2848
|
+
return { added, removed };
|
|
2849
|
+
}
|
|
2850
|
+
function tryParseNdjsonFromCommand(command, stdout) {
|
|
2851
|
+
if (!stdout.trim()) return null;
|
|
2852
|
+
const c = command.toLowerCase();
|
|
2853
|
+
const looksLike = (re) => re.test(c);
|
|
2854
|
+
if (looksLike(/\bdf\b/) || /^Filesystem\s+\S+\s+Used\s+/m.test(stdout)) {
|
|
2855
|
+
return parseDf(stdout);
|
|
2856
|
+
}
|
|
2857
|
+
if (looksLike(/\bfree\b/) || /^\s*total\s+used\s+free/m.test(stdout)) {
|
|
2858
|
+
return parseFree(stdout);
|
|
2859
|
+
}
|
|
2860
|
+
if (looksLike(/\bps\b/) || /^USER\s+PID\s+%CPU\s+%MEM/m.test(stdout)) {
|
|
2861
|
+
return parsePsAux(stdout);
|
|
2862
|
+
}
|
|
2863
|
+
if (looksLike(/\bss\b/) || /^State\s+Recv-Q\s+Send-Q/m.test(stdout)) {
|
|
2864
|
+
return parseSs(stdout);
|
|
2865
|
+
}
|
|
2866
|
+
if (looksLike(/\blsblk\b/) || /^NAME\s+MAJ:MIN/m.test(stdout)) {
|
|
2867
|
+
return parseLsblk(stdout);
|
|
2868
|
+
}
|
|
2869
|
+
return null;
|
|
2870
|
+
}
|
|
2871
|
+
function splitCols(line, max) {
|
|
2872
|
+
const parts = line.trim().split(/\s+/);
|
|
2873
|
+
if (max && parts.length > max) {
|
|
2874
|
+
return [...parts.slice(0, max - 1), parts.slice(max - 1).join(" ")];
|
|
2875
|
+
}
|
|
2876
|
+
return parts;
|
|
2877
|
+
}
|
|
2878
|
+
function parseDf(s) {
|
|
2879
|
+
const lines = s.split("\n").filter(Boolean);
|
|
2880
|
+
const header = lines[0];
|
|
2881
|
+
if (!header || !/Filesystem/i.test(header)) return null;
|
|
2882
|
+
const cleanHeader = header.replace(/Mounted on/i, "MountedOn");
|
|
2883
|
+
const cols = cleanHeader.trim().split(/\s+/);
|
|
2884
|
+
const out = [];
|
|
2885
|
+
for (const ln of lines.slice(1)) {
|
|
2886
|
+
const parts = splitCols(ln, cols.length);
|
|
2887
|
+
if (parts.length < 4) continue;
|
|
2888
|
+
const obj = {};
|
|
2889
|
+
for (let i = 0; i < cols.length; i++) obj[cols[i]] = parts[i] || "";
|
|
2890
|
+
out.push(JSON.stringify(obj));
|
|
2891
|
+
}
|
|
2892
|
+
return out.length ? out.join("\n") : null;
|
|
2893
|
+
}
|
|
2894
|
+
function parseFree(s) {
|
|
2895
|
+
const lines = s.split("\n").filter(Boolean);
|
|
2896
|
+
const out = [];
|
|
2897
|
+
for (const ln of lines) {
|
|
2898
|
+
if (/^\s*total\s+used\s+free/i.test(ln)) continue;
|
|
2899
|
+
const m = ln.match(/^(\S+):?\s+(.+)$/);
|
|
2900
|
+
if (!m) continue;
|
|
2901
|
+
const [type, rest] = [m[1], m[2]];
|
|
2902
|
+
const cols = rest.trim().split(/\s+/);
|
|
2903
|
+
out.push(JSON.stringify({ type: type.replace(/:$/, ""), values: cols.map(Number) }));
|
|
2904
|
+
}
|
|
2905
|
+
return out.length ? out.join("\n") : null;
|
|
2906
|
+
}
|
|
2907
|
+
function parsePsAux(s) {
|
|
2908
|
+
const lines = s.split("\n").filter(Boolean);
|
|
2909
|
+
if (!lines[0] || !/USER\s+PID/i.test(lines[0])) return null;
|
|
2910
|
+
const headers = lines[0].trim().split(/\s+/);
|
|
2911
|
+
const out = [];
|
|
2912
|
+
for (const ln of lines.slice(1)) {
|
|
2913
|
+
const parts = splitCols(ln, headers.length);
|
|
2914
|
+
if (parts.length < headers.length) continue;
|
|
2915
|
+
const obj = {};
|
|
2916
|
+
for (let i = 0; i < headers.length; i++) obj[headers[i]] = parts[i] || "";
|
|
2917
|
+
out.push(JSON.stringify(obj));
|
|
2918
|
+
}
|
|
2919
|
+
return out.length ? out.join("\n") : null;
|
|
2920
|
+
}
|
|
2921
|
+
function parseSs(s) {
|
|
2922
|
+
const lines = s.split("\n").filter(Boolean);
|
|
2923
|
+
if (!lines[0] || !/(State|Netid)/i.test(lines[0])) return null;
|
|
2924
|
+
const headers = lines[0].trim().split(/\s+/);
|
|
2925
|
+
const out = [];
|
|
2926
|
+
for (const ln of lines.slice(1)) {
|
|
2927
|
+
const parts = splitCols(ln, headers.length);
|
|
2928
|
+
const obj = {};
|
|
2929
|
+
for (let i = 0; i < headers.length; i++) obj[headers[i]] = parts[i] || "";
|
|
2930
|
+
out.push(JSON.stringify(obj));
|
|
2931
|
+
}
|
|
2932
|
+
return out.length ? out.join("\n") : null;
|
|
2933
|
+
}
|
|
2934
|
+
function parseLsblk(s) {
|
|
2935
|
+
const lines = s.split("\n").filter(Boolean);
|
|
2936
|
+
if (!lines[0] || !/NAME\s+MAJ:MIN/i.test(lines[0])) return null;
|
|
2937
|
+
const headers = lines[0].trim().split(/\s+/);
|
|
2938
|
+
const out = [];
|
|
2939
|
+
for (const ln of lines.slice(1)) {
|
|
2940
|
+
const parts = splitCols(ln, headers.length);
|
|
2941
|
+
const obj = {};
|
|
2942
|
+
for (let i = 0; i < headers.length; i++) obj[headers[i]] = parts[i] || "";
|
|
2943
|
+
out.push(JSON.stringify(obj));
|
|
2944
|
+
}
|
|
2945
|
+
return out.length ? out.join("\n") : null;
|
|
2946
|
+
}
|
|
2947
|
+
async function resolveServerLabel(serverId, serverIds) {
|
|
2948
|
+
if (Array.isArray(serverIds) && serverIds.length > 0) return `${serverIds.length} servers`;
|
|
2949
|
+
if (!serverId) return void 0;
|
|
2950
|
+
const cached = KNOWN_SERVER_NAMES.get(serverId);
|
|
2951
|
+
if (cached) return cached.name;
|
|
2952
|
+
try {
|
|
2953
|
+
const { data } = await supabase.from("ssh_server").select("id, name").eq("id", serverId).maybeSingle();
|
|
2954
|
+
if (data?.name) {
|
|
2955
|
+
KNOWN_SERVER_NAMES.set(serverId, { id: serverId, name: data.name });
|
|
2956
|
+
return data.name;
|
|
2957
|
+
}
|
|
2958
|
+
} catch {
|
|
2959
|
+
}
|
|
2960
|
+
return serverId.slice(0, 8);
|
|
2961
|
+
}
|
|
2504
2962
|
async function sftpWrite(opts, filePath, input, proxy, meta) {
|
|
2505
2963
|
const safe = sanitizePath(filePath);
|
|
2506
2964
|
assertWritablePath(safe);
|
|
@@ -2701,6 +3159,92 @@ async function sftpDelete(opts, filePath, proxy, options) {
|
|
|
2701
3159
|
return `Error: ${e.message}`;
|
|
2702
3160
|
}
|
|
2703
3161
|
}
|
|
3162
|
+
async function sftpCopy(src, dst) {
|
|
3163
|
+
const startedAt = Date.now();
|
|
3164
|
+
if (src.kind === "r2" && dst.kind === "r2") {
|
|
3165
|
+
const { size } = await r2CopyObject(src.bucket, src.key, dst.bucket, dst.key);
|
|
3166
|
+
return { bytes: size, elapsedMs: Date.now() - startedAt, mode: "r2-server-side" };
|
|
3167
|
+
}
|
|
3168
|
+
if (src.kind === "r2" && dst.kind === "ssh") {
|
|
3169
|
+
const safe = sanitizePath(dst.path);
|
|
3170
|
+
assertWritablePath(safe);
|
|
3171
|
+
const { stream, size } = await r2GetObjectStream(src.bucket, src.key);
|
|
3172
|
+
const { client, cleanup } = await connectSshClient(dst.opts, dst.proxy, 6e4);
|
|
3173
|
+
try {
|
|
3174
|
+
await new Promise((resolve, reject) => {
|
|
3175
|
+
client.sftp((err, sftp) => {
|
|
3176
|
+
if (err) return reject(err);
|
|
3177
|
+
const ws = sftp.createWriteStream(safe);
|
|
3178
|
+
ws.on("close", () => resolve());
|
|
3179
|
+
ws.on("error", reject);
|
|
3180
|
+
stream.on("error", reject);
|
|
3181
|
+
stream.pipe(ws);
|
|
3182
|
+
});
|
|
3183
|
+
});
|
|
3184
|
+
} finally {
|
|
3185
|
+
cleanup();
|
|
3186
|
+
}
|
|
3187
|
+
return { bytes: size, elapsedMs: Date.now() - startedAt, mode: "r2\u2192ssh-stream" };
|
|
3188
|
+
}
|
|
3189
|
+
if (src.kind === "ssh" && dst.kind === "r2") {
|
|
3190
|
+
const safeSrc = sanitizePath(src.path);
|
|
3191
|
+
const { client, cleanup } = await connectSshClient(src.opts, src.proxy, 6e4);
|
|
3192
|
+
try {
|
|
3193
|
+
const { stream, size } = await new Promise((resolve, reject) => {
|
|
3194
|
+
client.sftp((err, sftp) => {
|
|
3195
|
+
if (err) return reject(err);
|
|
3196
|
+
sftp.stat(safeSrc, (statErr, stats) => {
|
|
3197
|
+
if (statErr) return reject(statErr);
|
|
3198
|
+
const s = stats.size || 0;
|
|
3199
|
+
const rs = sftp.createReadStream(safeSrc);
|
|
3200
|
+
resolve({ stream: rs, size: s });
|
|
3201
|
+
});
|
|
3202
|
+
});
|
|
3203
|
+
});
|
|
3204
|
+
if (size > R2_MULTIPART_THRESHOLD) {
|
|
3205
|
+
cleanup();
|
|
3206
|
+
throw new Error(`Source object is ${formatBytes(size)} which exceeds R2 single-PUT cap (${formatBytes(R2_MULTIPART_THRESHOLD)}). For now: download via sftp-write { sourceRemote, target: local } first, then upload to R2 with sourcePath.`);
|
|
3207
|
+
}
|
|
3208
|
+
await r2PutObject(dst.bucket, dst.key, stream, size);
|
|
3209
|
+
return { bytes: size, elapsedMs: Date.now() - startedAt, mode: "ssh\u2192r2-stream" };
|
|
3210
|
+
} finally {
|
|
3211
|
+
cleanup();
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
if (src.kind === "ssh" && dst.kind === "ssh") {
|
|
3215
|
+
const safeSrc = sanitizePath(src.path);
|
|
3216
|
+
const safeDst = sanitizePath(dst.path);
|
|
3217
|
+
assertWritablePath(safeDst);
|
|
3218
|
+
const srcConn = await connectSshClient(src.opts, src.proxy, 6e4);
|
|
3219
|
+
let dstConn;
|
|
3220
|
+
try {
|
|
3221
|
+
dstConn = await connectSshClient(dst.opts, dst.proxy, 6e4);
|
|
3222
|
+
const bytes = await new Promise((resolve, reject) => {
|
|
3223
|
+
srcConn.client.sftp((err, srcSftp) => {
|
|
3224
|
+
if (err) return reject(err);
|
|
3225
|
+
dstConn.client.sftp((err2, dstSftp) => {
|
|
3226
|
+
if (err2) return reject(err2);
|
|
3227
|
+
srcSftp.stat(safeSrc, (statErr, stats) => {
|
|
3228
|
+
if (statErr) return reject(statErr);
|
|
3229
|
+
const size = stats.size || 0;
|
|
3230
|
+
const rs = srcSftp.createReadStream(safeSrc);
|
|
3231
|
+
const ws = dstSftp.createWriteStream(safeDst);
|
|
3232
|
+
ws.on("close", () => resolve(size));
|
|
3233
|
+
ws.on("error", reject);
|
|
3234
|
+
rs.on("error", reject);
|
|
3235
|
+
rs.pipe(ws);
|
|
3236
|
+
});
|
|
3237
|
+
});
|
|
3238
|
+
});
|
|
3239
|
+
});
|
|
3240
|
+
return { bytes, elapsedMs: Date.now() - startedAt, mode: "ssh\u2192ssh-stream" };
|
|
3241
|
+
} finally {
|
|
3242
|
+
srcConn.cleanup();
|
|
3243
|
+
dstConn?.cleanup();
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
throw new Error("Unsupported copy combination");
|
|
3247
|
+
}
|
|
2704
3248
|
var BLOCKED_COMMANDS = [
|
|
2705
3249
|
"rm -rf /",
|
|
2706
3250
|
"rm -fr /",
|
|
@@ -2885,33 +3429,41 @@ async function mijnhostFetch(path, options = {}) {
|
|
|
2885
3429
|
var TOOLS = [
|
|
2886
3430
|
{
|
|
2887
3431
|
name: "list-servers",
|
|
2888
|
-
description:
|
|
3432
|
+
description: 'List all SSH servers you have access to. Returns id, name, hostname, tags, and os_type per server. Pass `includeStats: true` to also probe each server in parallel for container count, disk-free, and uptime (skips unreachable hosts gracefully). Cached for 60s \u2014 pass `noCache: true` to force a refresh. Adds an "auto-context" footer with one-line tags inferred from server name/tags so the LLM knows which host runs Trigger / Supabase / proxy / etc. (disable with `context: false`).',
|
|
2889
3433
|
inputSchema: {
|
|
2890
3434
|
type: "object",
|
|
2891
3435
|
properties: {
|
|
2892
3436
|
includeStats: { type: "boolean", description: "When true, probe each server (in parallel, with timeout) for container count, disk-free, and uptime." },
|
|
2893
3437
|
statsParallelism: { type: "number", description: "Max parallel probes when includeStats is true (default 8, cap 20)." },
|
|
2894
|
-
statsTimeoutMs: { type: "number", description: "Per-server probe timeout in ms when includeStats is true (default 6000, range 1000-30000)." }
|
|
3438
|
+
statsTimeoutMs: { type: "number", description: "Per-server probe timeout in ms when includeStats is true (default 6000, range 1000-30000)." },
|
|
3439
|
+
context: { type: "boolean", description: "Include auto-context footer with name/tag-based hints (default true). Pass false to suppress." },
|
|
3440
|
+
noCache: { type: "boolean", description: "Bypass the 60s in-memory cache." }
|
|
2895
3441
|
}
|
|
2896
3442
|
}
|
|
2897
3443
|
},
|
|
2898
3444
|
{
|
|
2899
3445
|
name: "ssh-execute",
|
|
2900
|
-
description: 'Execute
|
|
3446
|
+
description: 'Execute one or more commands on a remote server via SSH. OS-aware: bash on linux, `powershell -EncodedCommand` (UTF-16LE base64) on windows, so $, #, quotes, spaces inside `args` are never re-interpreted by a shell. Some dangerous commands are blocked. Use `list-servers` first to see each server\'s os_type.\n\nThree modes (compose them in one call):\n- Single: `command` (+ optional `args[]`). Quick or safe.\n- Pipeline: `pipeline: ["cmd1","cmd2","cmd3"]` runs all commands sequentially in one SSH session and returns segmented per-command output (each with its own exit code). Default continues on error; pass `pipelineStopOnError: true` to abort.\n- Repeat: `repeat: N, intervalSeconds: S, output: "all"|"diff"|"last"`. Diff mode shows only added/removed lines vs. the previous run \u2014 perfect for "watch this metric for 5 min".\n\n`stdin` pipes data into the remote process (queries, scripts, secrets) without putting it on the command line. `cwd` sets the working directory (auto `cd` on Linux / `Set-Location` on Windows).\n\nIdle timeout: defaults to 120s \u2014 if the remote produces no stdout/stderr for that long the call returns with a clear timeout message. Pass `idleTimeoutSeconds: 0` to disable, or e.g. `300` for slow workloads.\n\nFan-out: pass `serverIds: [id1, id2, ...]` (instead of `serverId`) to run across multiple servers in parallel.\n\nAuto-features: outputs >8 KB are auto-truncated with a continuation hint; transient SSH failures (ECONNRESET/ETIMEDOUT/etc) get one automatic retry. Set `format: "ndjson"` to parse common Linux outputs (df / free / ps aux / ss / lsblk) into newline-delimited JSON instead of whitespace-padded text.',
|
|
2901
3447
|
inputSchema: {
|
|
2902
3448
|
type: "object",
|
|
2903
3449
|
properties: {
|
|
2904
3450
|
serverId: { type: "string", description: "UUID of one SSH server. Mutually exclusive with serverIds." },
|
|
2905
3451
|
serverIds: { type: "array", items: { type: "string" }, description: "Fan-out: list of server UUIDs to run the command on in parallel. Mutually exclusive with serverId." },
|
|
2906
|
-
command: { type: "string", description: 'Program/command to run (e.g. "mysql", "Get-Service", "df"). Required.' },
|
|
3452
|
+
command: { type: "string", description: 'Program/command to run (e.g. "mysql", "Get-Service", "df"). Required unless `pipeline[]` is set.' },
|
|
2907
3453
|
args: { type: "array", items: { type: "string" }, description: "Optional argv list. When provided, each entry is safely quoted/encoded for the target OS." },
|
|
3454
|
+
pipeline: { type: "array", items: { type: "string" }, description: "Run multiple shell commands sequentially in ONE SSH session, with per-command exit codes and segmented output. Saves N round-trips for setup\u2192test\u2192cleanup workflows." },
|
|
3455
|
+
pipelineStopOnError: { type: "boolean", description: "When pipeline is set, abort the chain on the first non-zero exit (default false: keep going so all outputs reach the LLM)." },
|
|
3456
|
+
repeat: { type: "number", description: 'Re-run the command/pipeline N times (default 1, max 60). Combine with intervalSeconds + output to "watch" a metric.' },
|
|
3457
|
+
intervalSeconds: { type: "number", description: "When repeat>1, seconds to wait between iterations (default 0, max 600)." },
|
|
3458
|
+
output: { type: "string", enum: ["all", "diff", "last"], description: "When repeat>1: how to render iterations. `all` = full output per run; `diff` = only added/removed lines vs. previous run; `last` = only the final iteration." },
|
|
3459
|
+
format: { type: "string", enum: ["text", "ndjson"], description: "Output format. `ndjson` parses common Linux outputs (df, free, ps aux, ss, lsblk) into newline-delimited JSON." },
|
|
2908
3460
|
stdin: { type: "string", description: "Optional data piped to the remote process stdin." },
|
|
2909
3461
|
cwd: { type: "string", description: "Working directory on the remote (cd <cwd> on Linux, Set-Location on Windows)." },
|
|
2910
3462
|
shell: { type: "string", enum: ["auto", "bash", "powershell"], description: `Override shell selection (default "auto" uses the server's os_type).` },
|
|
2911
3463
|
parallelism: { type: "number", description: "Max concurrent SSH sessions when using serverIds[] (default 5, max 20)." },
|
|
2912
|
-
timeout: { type: "number", description: "
|
|
2913
|
-
|
|
2914
|
-
|
|
3464
|
+
timeout: { type: "number", description: "Wall timeout in milliseconds (default: 60000). Usually idleTimeoutSeconds is what you want instead." },
|
|
3465
|
+
idleTimeoutSeconds: { type: "number", description: 'Abort if no output for N seconds (default 120). Pass 0 to disable. Useful when the wall budget is hard to predict but silence usually means "stuck".' }
|
|
3466
|
+
}
|
|
2915
3467
|
}
|
|
2916
3468
|
},
|
|
2917
3469
|
{
|
|
@@ -2950,65 +3502,73 @@ var TOOLS = [
|
|
|
2950
3502
|
},
|
|
2951
3503
|
{
|
|
2952
3504
|
name: "sftp-write",
|
|
2953
|
-
description: "Write a file
|
|
3505
|
+
description: "Write OR copy a file in remote storage. Two target backends (`serverId` or `bucket`) and three source modes:\n\nSources:\n- `content` (string): inline UTF-8 text (\u22481 MB max).\n- `sourcePath` (absolute local path): streams a local file. SSH uses ssh2 fastPut; R2 uses streamed PUT (or automatic multipart for >4.5 GB). Handles GB-scale.\n- `sourceRemote: { serverId|bucket, path }`: COPY mode \u2014 pulls from one remote and writes to another in a single operation, no local detour. Supports all four directions: ssh\u2194ssh, ssh\u2194r2, r2\u2194ssh, r2\u2194r2 (R2\u2194R2 uses server-side CopyObject = zero data egress).\n\nTargets:\n- `serverId`: SSH server via SFTP. Protected system paths blocked. Optional `mode` (POSIX bits e.g. 0o755) and `mtime` (modification time) \u2014 SSH only.\n- `bucket`: Cloudflare R2 bucket. `mode`/`mtime` are ignored on R2.",
|
|
2954
3506
|
inputSchema: {
|
|
2955
3507
|
type: "object",
|
|
2956
3508
|
properties: {
|
|
2957
|
-
serverId: { type: "string", description: "UUID of the SSH server (mutually exclusive with bucket)" },
|
|
2958
|
-
bucket: { type: "string", description: "Cloudflare R2 bucket name (mutually exclusive with serverId)" },
|
|
2959
|
-
path: { type: "string", description: "
|
|
2960
|
-
content: { type: "string", description: "
|
|
2961
|
-
sourcePath: { type: "string", description: "
|
|
2962
|
-
|
|
2963
|
-
|
|
3509
|
+
serverId: { type: "string", description: "TARGET: UUID of the SSH server (mutually exclusive with bucket)" },
|
|
3510
|
+
bucket: { type: "string", description: "TARGET: Cloudflare R2 bucket name (mutually exclusive with serverId)" },
|
|
3511
|
+
path: { type: "string", description: "TARGET path (SSH file path or R2 object key)" },
|
|
3512
|
+
content: { type: "string", description: "Source: inline UTF-8 file content (mutually exclusive with sourcePath / sourceRemote)" },
|
|
3513
|
+
sourcePath: { type: "string", description: "Source: absolute local file path (mutually exclusive with content / sourceRemote). Use for >1 MB or binary." },
|
|
3514
|
+
sourceRemote: { type: "object", description: "Source: copy from another remote. Shape: { serverId?: string, bucket?: string, path: string }. Picks the streaming strategy (ssh\u2192ssh, r2\u2192ssh, ssh\u2192r2, r2\u2192r2 server-side).", properties: { serverId: { type: "string" }, bucket: { type: "string" }, path: { type: "string" } } },
|
|
3515
|
+
mode: { oneOf: [{ type: "number" }, { type: "string" }], description: 'POSIX file permission bits (SSH target only). Number (0o755 / 493) or octal string ("755" / "0o755"). Default 0o644.' },
|
|
3516
|
+
mtime: { oneOf: [{ type: "number" }, { type: "string" }], description: "File modification time (SSH target only). ms-since-epoch number or ISO-8601 date string." }
|
|
2964
3517
|
},
|
|
2965
3518
|
required: ["path"]
|
|
2966
3519
|
}
|
|
2967
3520
|
},
|
|
2968
3521
|
{
|
|
2969
3522
|
name: "sftp-delete",
|
|
2970
|
-
description:
|
|
3523
|
+
description: 'Delete a file (or directory tree) from remote storage. Two backends (provide exactly one of `serverId` or `bucket`):\n- `serverId`: delete a file or empty directory on an SSH server via SFTP. Protected system paths blocked. With `recursive: true` walks the tree depth-first via SFTP and removes files + empty dirs from the bottom up (works on Windows OpenSSH too \u2014 no shell tricks).\n- `bucket`: delete an object from a Cloudflare R2 bucket. `path` is the object key. With `recursive: true`, treats `path` as a key prefix and batch-deletes every object beneath it.\n\nSafety nets:\n- `dryRun: true` \u2192 returns "Would delete N items (size)" with a sample preview, no actual deletion.\n- `confirmAbove: N` + `confirmCount: <exact>` \u2192 refuses recursive deletes touching more than N items unless the caller passes `confirmCount` matching the actual count.',
|
|
2971
3524
|
inputSchema: {
|
|
2972
3525
|
type: "object",
|
|
2973
3526
|
properties: {
|
|
2974
3527
|
serverId: { type: "string", description: "UUID of the SSH server (mutually exclusive with bucket)" },
|
|
2975
3528
|
bucket: { type: "string", description: "Cloudflare R2 bucket name (mutually exclusive with serverId)" },
|
|
2976
3529
|
path: { type: "string", description: "File path / object key (or directory / key prefix when recursive)" },
|
|
2977
|
-
recursive: { type: "boolean", description: "Recursively remove a directory tree (SSH) or all objects under a key prefix (R2)." }
|
|
3530
|
+
recursive: { type: "boolean", description: "Recursively remove a directory tree (SSH) or all objects under a key prefix (R2)." },
|
|
3531
|
+
dryRun: { type: "boolean", description: "Preview-only mode: returns what would be deleted (count, total size, sample paths) without touching anything." },
|
|
3532
|
+
confirmAbove: { type: "number", description: "Refuse recursive deletes that touch more than N items unless `confirmCount` matches the actual count." },
|
|
3533
|
+
confirmCount: { type: "number", description: "When confirmAbove gates a delete, pass the exact item count from the dry-run output to confirm intent." }
|
|
2978
3534
|
},
|
|
2979
3535
|
required: ["path"]
|
|
2980
3536
|
}
|
|
2981
3537
|
},
|
|
2982
3538
|
{
|
|
2983
3539
|
name: "docker-list",
|
|
2984
|
-
description:
|
|
3540
|
+
description: 'List Docker containers on one or more remote servers. Sorted by compose project then name, with a HEALTH column (healthy / unhealthy / starting / no-check). Filter by `nameFilter` (string OR array of substring/glob entries \u2014 passes if ANY entry matches), restrict to compose containers with `composeOnly`, or group output per project with `groupByProject`.\n\nFan-out: pass `serverIds: [...]` (instead of `serverId`) to query multiple servers in parallel \u2014 perfect for "show all unhealthy containers across the fleet". Per-server sections are clearly headed.\n\nCached for 30s \u2014 pass `noCache: true` to bypass.',
|
|
2985
3541
|
inputSchema: {
|
|
2986
3542
|
type: "object",
|
|
2987
3543
|
properties: {
|
|
2988
|
-
serverId: { type: "string", description: "UUID of
|
|
2989
|
-
|
|
3544
|
+
serverId: { type: "string", description: "UUID of one SSH server. Mutually exclusive with serverIds." },
|
|
3545
|
+
serverIds: { type: "array", items: { type: "string" }, description: "Fan-out: list of server UUIDs to query in parallel. Mutually exclusive with serverId." },
|
|
3546
|
+
format: { type: "string", enum: ["table", "json"], description: 'Output format: human table (default) or NDJSON. JSON adds a "Server" field per row in fan-out mode.' },
|
|
2990
3547
|
composeOnly: { type: "boolean", description: "Only show containers that have a docker-compose project label." },
|
|
2991
3548
|
nameFilter: { oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], description: "Filter by container name. Single entry or array; each entry is glob (* or ?) or substring. A row passes when ANY entry matches." },
|
|
2992
|
-
groupByProject: { type: "boolean", description: "Group output per compose project with a `=== project (count) ===` header." }
|
|
2993
|
-
|
|
2994
|
-
|
|
3549
|
+
groupByProject: { type: "boolean", description: "Group output per compose project with a `=== project (count) ===` header (single-server mode only)." },
|
|
3550
|
+
parallelism: { type: "number", description: "Max concurrent SSH sessions when using serverIds[] (default 5, max 20)." },
|
|
3551
|
+
noCache: { type: "boolean", description: "Bypass the 30s in-memory cache." }
|
|
3552
|
+
}
|
|
2995
3553
|
}
|
|
2996
3554
|
},
|
|
2997
3555
|
{
|
|
2998
3556
|
name: "docker-logs",
|
|
2999
|
-
description: 'Get logs from one or more Docker containers. `containerName` accepts a string OR
|
|
3557
|
+
description: 'Get logs from one or more Docker containers, optionally across multiple servers. `containerName` accepts a string OR array \u2014 multi-container output is line-prefixed with `[name]`. Supports time-window (`since`), server-side `grep`, line-count (`tail`), and real-time follow (`followSeconds`). Always merges stderr into stdout. Outputs >8 KB get a continuation hint.\n\nFan-out: pass `serverIds: [...]` to tail the same container(s) across multiple servers in one call (e.g. all kong instances). Per-server output is grouped under headers.\n\nFuzzy hint: if a container name typo is detected, the error includes "Did you mean: ..." suggestions from the docker-list cache.',
|
|
3000
3558
|
inputSchema: {
|
|
3001
3559
|
type: "object",
|
|
3002
3560
|
properties: {
|
|
3003
|
-
serverId: { type: "string", description: "UUID of
|
|
3561
|
+
serverId: { type: "string", description: "UUID of one SSH server. Mutually exclusive with serverIds." },
|
|
3562
|
+
serverIds: { type: "array", items: { type: "string" }, description: "Fan-out: list of server UUIDs to tail in parallel. Mutually exclusive with serverId." },
|
|
3004
3563
|
containerName: { oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], description: "Single container name/ID or an array for multi-container tail. Multi mode prefixes each line with [name]." },
|
|
3005
|
-
tail: { type: "number", description: "Number of recent log lines per container (default 100).
|
|
3564
|
+
tail: { type: "number", description: "Number of recent log lines per container (default 100, default 0 in follow mode)." },
|
|
3006
3565
|
lines: { type: "number", description: "Deprecated alias for `tail`. Prefer `tail`." },
|
|
3007
3566
|
since: { type: "string", description: 'Time window, e.g. "10m", "2h", "24h", or an absolute "2026-05-09T10:00:00".' },
|
|
3008
3567
|
grep: { type: "string", description: "Case-insensitive regex/literal filter applied server-side (saves tokens for noisy containers)." },
|
|
3009
|
-
followSeconds: { type: "number", description: "When >0, stream new log lines for N seconds via `docker logs -f`, then exit (max 300). Multi-container follow runs all streams in parallel." }
|
|
3568
|
+
followSeconds: { type: "number", description: "When >0, stream new log lines for N seconds via `docker logs -f`, then exit (max 300). Multi-container follow runs all streams in parallel." },
|
|
3569
|
+
parallelism: { type: "number", description: "Max concurrent SSH sessions when using serverIds[] (default 5, max 20)." }
|
|
3010
3570
|
},
|
|
3011
|
-
required: ["
|
|
3571
|
+
required: ["containerName"]
|
|
3012
3572
|
}
|
|
3013
3573
|
},
|
|
3014
3574
|
{
|
|
@@ -3046,6 +3606,24 @@ var TOOLS = [
|
|
|
3046
3606
|
required: ["serverId", "projectPath", "action"]
|
|
3047
3607
|
}
|
|
3048
3608
|
},
|
|
3609
|
+
{
|
|
3610
|
+
name: "wait-for",
|
|
3611
|
+
description: 'Poll until a condition holds (or timeout). Replaces 30+ ad-hoc loops the LLM would otherwise build itself. Four condition types:\n- `url`: `target: "https://..."`, `until: 200` (HTTP status, default 200). 10s per probe.\n- `container-health`: `serverId`, `target: "container-name"`, `until: "healthy" | "running" | "exited"` (default "healthy"). Uses `docker inspect`.\n- `file-exists`: `serverId`, `target: "/path/to/file"` \u2014 SSH stat check.\n- `sftp-exists`: same as file-exists but with `bucket: "name"` instead of serverId for R2.\n\nReturns a per-attempt trail so you can see how long it took to converge.',
|
|
3612
|
+
inputSchema: {
|
|
3613
|
+
type: "object",
|
|
3614
|
+
properties: {
|
|
3615
|
+
type: { type: "string", enum: ["url", "container-health", "file-exists", "sftp-exists"], description: "Condition type to wait on." },
|
|
3616
|
+
target: { type: "string", description: "Target identifier: URL, container name, file path, or R2 key (depends on type)." },
|
|
3617
|
+
until: { description: 'Expected end-state (depends on type): for url an HTTP status (default 200), for container-health a state string (default "healthy"). Omit for file-exists/sftp-exists.' },
|
|
3618
|
+
serverId: { type: "string", description: "SSH server UUID (required for container-health and file-exists)." },
|
|
3619
|
+
bucket: { type: "string", description: "R2 bucket name (used with sftp-exists when checking an R2 object)." },
|
|
3620
|
+
path: { type: "string", description: "Alias for `target` for file-exists/sftp-exists." },
|
|
3621
|
+
intervalSeconds: { type: "number", description: "Poll interval in seconds (default 3, max 60)." },
|
|
3622
|
+
timeout: { type: "number", description: "Total deadline in seconds (default 120, max 1800)." }
|
|
3623
|
+
},
|
|
3624
|
+
required: ["type", "target"]
|
|
3625
|
+
}
|
|
3626
|
+
},
|
|
3049
3627
|
{
|
|
3050
3628
|
name: "db-discover",
|
|
3051
3629
|
description: "Scan /var/www on a server for web applications (WordPress, PrestaShop, Laravel, .env) and list their database credentials. Use this first to find available sites before running other db-* tools.",
|
|
@@ -3084,15 +3662,20 @@ var TOOLS = [
|
|
|
3084
3662
|
},
|
|
3085
3663
|
{
|
|
3086
3664
|
name: "db-query",
|
|
3087
|
-
description:
|
|
3665
|
+
description: 'Execute a SQL query against any database. Two routing modes:\n- MySQL via `/var/www` autodiscover (default): pass `sitePath` (and `engine: "mysql"` implicitly). Credentials are read from wp-config.php, parameters.php, .env. Same behaviour as before.\n- Direct via Docker container: pass `containerName` (the container running the DB server). Engine defaults to `postgres`; pass `engine: "mysql"` or `"mssql"` to override. The query is piped via stdin so quotes / `$` / `;` are never re-interpreted.\n\nPostgres example: `{ serverId, containerName: "supabase-db", engine: "postgres", dbName: "main", dbUser: "postgres", query: "SELECT 1" }`. Defaults: dbUser=postgres, dbName=postgres.\nMSSQL example: `{ serverId, containerName: "mssql-1", engine: "mssql", dbUser: "sa", dbPass: "...", query: "SELECT 1" }`.\n\nDestructive operations (DROP, TRUNCATE, etc.) are blocked.',
|
|
3088
3666
|
inputSchema: {
|
|
3089
3667
|
type: "object",
|
|
3090
3668
|
properties: {
|
|
3091
3669
|
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
3092
|
-
|
|
3093
|
-
|
|
3670
|
+
query: { type: "string", description: "SQL query to execute" },
|
|
3671
|
+
sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com). Used only with engine=mysql autodiscover." },
|
|
3672
|
+
containerName: { type: "string", description: 'Docker container running the DB server (e.g. "supabase-db", "trigger-postgres"). Activates direct-query mode.' },
|
|
3673
|
+
engine: { type: "string", enum: ["mysql", "postgres", "mssql"], description: 'DB engine. Defaults to "mysql" with sitePath, "postgres" with containerName.' },
|
|
3674
|
+
dbName: { type: "string", description: 'Database name (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 server default, mssql \u2192 server default.' },
|
|
3675
|
+
dbUser: { type: "string", description: 'Database user (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 "root", mssql \u2192 "sa".' },
|
|
3676
|
+
dbPass: { type: "string", description: "Database password (containerName mode). Required for mssql; optional for mysql; postgres uses trust auth by default." }
|
|
3094
3677
|
},
|
|
3095
|
-
required: ["serverId", "
|
|
3678
|
+
required: ["serverId", "query"]
|
|
3096
3679
|
}
|
|
3097
3680
|
},
|
|
3098
3681
|
{
|
|
@@ -3238,9 +3821,21 @@ async function handleCallTool(request) {
|
|
|
3238
3821
|
}
|
|
3239
3822
|
const startTime = Date.now();
|
|
3240
3823
|
const serverId = a.serverId || a.server_id;
|
|
3241
|
-
const
|
|
3824
|
+
const serverIds = a.serverIds;
|
|
3825
|
+
const cached = getCachedTool(name, a);
|
|
3826
|
+
let cameFromCache = false;
|
|
3827
|
+
let result;
|
|
3828
|
+
if (cached) {
|
|
3829
|
+
result = cached;
|
|
3830
|
+
cameFromCache = true;
|
|
3831
|
+
} else {
|
|
3832
|
+
result = await executeToolCall(name, a);
|
|
3833
|
+
}
|
|
3242
3834
|
const durationMs = Date.now() - startTime;
|
|
3243
3835
|
const isError = result.content?.[0]?.text?.startsWith("Error:");
|
|
3836
|
+
if (!cameFromCache && !isError) {
|
|
3837
|
+
setCachedTool(name, a, result);
|
|
3838
|
+
}
|
|
3244
3839
|
void writeAuditLog({
|
|
3245
3840
|
toolName: name,
|
|
3246
3841
|
arguments: a,
|
|
@@ -3249,7 +3844,14 @@ async function handleCallTool(request) {
|
|
|
3249
3844
|
errorMessage: isError ? result.content?.[0]?.text : void 0,
|
|
3250
3845
|
durationMs
|
|
3251
3846
|
});
|
|
3252
|
-
|
|
3847
|
+
const serverIdLabel = await resolveServerLabel(serverId, serverIds);
|
|
3848
|
+
return postprocessResult(result, {
|
|
3849
|
+
startedAtMs: startTime,
|
|
3850
|
+
serverIdLabel,
|
|
3851
|
+
toolName: name,
|
|
3852
|
+
args: a,
|
|
3853
|
+
cached: cameFromCache
|
|
3854
|
+
});
|
|
3253
3855
|
}
|
|
3254
3856
|
async function executeToolCall(name, a, _serverId) {
|
|
3255
3857
|
const ctx = authContext;
|
|
@@ -3265,6 +3867,7 @@ async function executeToolCall(name, a, _serverId) {
|
|
|
3265
3867
|
if (error) throw new Error(error.message);
|
|
3266
3868
|
const includeStats = a.includeStats === true;
|
|
3267
3869
|
const servers = data || [];
|
|
3870
|
+
rememberServers(servers.map((s) => ({ id: s.id, name: s.name })));
|
|
3268
3871
|
let statsByServer = /* @__PURE__ */ new Map();
|
|
3269
3872
|
if (includeStats && servers.length > 0) {
|
|
3270
3873
|
const parallelism = Math.max(1, Math.min(20, Number(a.statsParallelism) || 8));
|
|
@@ -3315,17 +3918,34 @@ async function executeToolCall(name, a, _serverId) {
|
|
|
3315
3918
|
if (!st.reachable) return `${base} UNREACHABLE (${st.error || "unknown"})`;
|
|
3316
3919
|
return `${base} containers:${st.containers} disk_free:${st.diskFreeHuman} uptime:${st.uptimeHuman}`;
|
|
3317
3920
|
});
|
|
3318
|
-
|
|
3921
|
+
const contextHints = a.context !== false ? buildServerContextHints(servers) : [];
|
|
3922
|
+
const out = lines.length ? lines.join("\n") : "No servers found";
|
|
3923
|
+
return { content: [{ type: "text", text: contextHints.length ? `${out}
|
|
3924
|
+
|
|
3925
|
+
--- context ---
|
|
3926
|
+
${contextHints.join("\n")}` : out }] };
|
|
3319
3927
|
}
|
|
3320
3928
|
// ----- SSH -----
|
|
3321
3929
|
case "ssh-execute": {
|
|
3322
|
-
const
|
|
3323
|
-
|
|
3930
|
+
const pipeline = Array.isArray(a.pipeline) ? a.pipeline.map(String).filter(Boolean) : [];
|
|
3931
|
+
const command = pipeline.length > 0 ? "" : String(a.command || "");
|
|
3932
|
+
if (!command && pipeline.length === 0) {
|
|
3933
|
+
return { content: [{ type: "text", text: "Error: pass either `command` or `pipeline[]`" }] };
|
|
3934
|
+
}
|
|
3935
|
+
if (command) assertSafeCommand(command);
|
|
3936
|
+
for (const c of pipeline) assertSafeCommand(c);
|
|
3324
3937
|
const args2 = Array.isArray(a.args) ? a.args.map(String) : void 0;
|
|
3325
3938
|
const stdin = typeof a.stdin === "string" ? a.stdin : void 0;
|
|
3326
3939
|
const shellOverride = typeof a.shell === "string" ? a.shell : "auto";
|
|
3327
3940
|
const cwd = typeof a.cwd === "string" && a.cwd ? a.cwd : void 0;
|
|
3328
3941
|
const timeoutMs = a.timeout ? Number(a.timeout) : void 0;
|
|
3942
|
+
const rawIdle = a.idleTimeoutSeconds !== void 0 ? Number(a.idleTimeoutSeconds) : 120;
|
|
3943
|
+
const idleTimeoutMs = Number.isFinite(rawIdle) && rawIdle > 0 ? rawIdle * 1e3 : void 0;
|
|
3944
|
+
const repeat = Math.max(1, Math.min(60, Number(a.repeat) || 1));
|
|
3945
|
+
const intervalSeconds = Math.max(0, Math.min(600, Number(a.intervalSeconds) || 0));
|
|
3946
|
+
const repeatOutput = a.output === "diff" || a.output === "last" ? a.output : "all";
|
|
3947
|
+
const format = a.format === "ndjson" ? "ndjson" : "text";
|
|
3948
|
+
const pipelineStopOnError = a.pipelineStopOnError === true;
|
|
3329
3949
|
const targetIds = Array.isArray(a.serverIds) && a.serverIds.length > 0 ? a.serverIds.map(String) : a.serverId ? [String(a.serverId)] : [];
|
|
3330
3950
|
if (targetIds.length === 0) {
|
|
3331
3951
|
return { content: [{ type: "text", text: "Error: pass either `serverId` or `serverIds[]`" }] };
|
|
@@ -3336,44 +3956,88 @@ async function executeToolCall(name, a, _serverId) {
|
|
|
3336
3956
|
if (timeoutMs) conn.timeout = timeoutMs;
|
|
3337
3957
|
const shell = shellOverride === "auto" ? os === "windows" ? "powershell" : "bash" : shellOverride;
|
|
3338
3958
|
let body;
|
|
3339
|
-
|
|
3959
|
+
let pipelineMarker = null;
|
|
3960
|
+
if (pipeline.length > 0) {
|
|
3961
|
+
pipelineMarker = `__MCP_BREAK_${randomBytes(8).toString("hex")}__`;
|
|
3962
|
+
body = buildPipelineScript(pipeline, shell, pipelineMarker, pipelineStopOnError);
|
|
3963
|
+
} else if (args2 && args2.length > 0) {
|
|
3340
3964
|
body = shell === "powershell" ? buildPowerShellEncodedCommand(command, args2) : buildPosixCommand(command, args2);
|
|
3341
3965
|
} else if (shell === "powershell" && !/^powershell\b/i.test(command.trim())) {
|
|
3342
3966
|
body = buildPowerShellEncodedCommand(command, []);
|
|
3343
3967
|
} else {
|
|
3344
3968
|
body = command;
|
|
3345
3969
|
}
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
if (shell === "bash") {
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3970
|
+
const applyCwd = (rawBody) => {
|
|
3971
|
+
if (!cwd) return rawBody;
|
|
3972
|
+
if (shell === "bash") return `cd ${posixQuote(cwd)} && ${rawBody}`;
|
|
3973
|
+
const ecMatch = rawBody.match(/-EncodedCommand\s+(\S+)$/);
|
|
3974
|
+
if (!ecMatch) return rawBody;
|
|
3975
|
+
const decoded = Buffer.from(ecMatch[1], "base64").toString("utf16le");
|
|
3976
|
+
const wrapped = `Set-Location -LiteralPath '${cwd.replace(/'/g, "''")}'; ${decoded}`;
|
|
3977
|
+
const reencoded = Buffer.from(wrapped, "utf16le").toString("base64");
|
|
3978
|
+
return rawBody.replace(/-EncodedCommand\s+\S+$/, `-EncodedCommand ${reencoded}`);
|
|
3979
|
+
};
|
|
3980
|
+
const finalCmd = applyCwd(body);
|
|
3981
|
+
if (repeat > 1) {
|
|
3982
|
+
const iterations = [];
|
|
3983
|
+
for (let i = 0; i < repeat; i++) {
|
|
3984
|
+
const startedAt = Date.now();
|
|
3985
|
+
const r = await sshExec(conn, finalCmd, proxy, {
|
|
3986
|
+
...stdin !== void 0 ? { stdin } : {},
|
|
3987
|
+
idleTimeoutMs
|
|
3988
|
+
});
|
|
3989
|
+
iterations.push({ index: i, startedAt, durationMs: Date.now() - startedAt, result: r });
|
|
3990
|
+
if (i < repeat - 1 && intervalSeconds > 0) {
|
|
3991
|
+
await new Promise((res) => setTimeout(res, intervalSeconds * 1e3));
|
|
3357
3992
|
}
|
|
3358
3993
|
}
|
|
3994
|
+
const last = iterations[iterations.length - 1].result;
|
|
3995
|
+
let serverName2 = serverId;
|
|
3996
|
+
try {
|
|
3997
|
+
const { data } = await supabase.from("ssh_server").select("name").eq("id", serverId).maybeSingle();
|
|
3998
|
+
if (data?.name) serverName2 = data.name;
|
|
3999
|
+
} catch {
|
|
4000
|
+
}
|
|
4001
|
+
return { serverId, serverName: serverName2, os, shell, result: last, iterations };
|
|
4002
|
+
}
|
|
4003
|
+
const result = await sshExec(conn, finalCmd, proxy, {
|
|
4004
|
+
...stdin !== void 0 ? { stdin } : {},
|
|
4005
|
+
idleTimeoutMs
|
|
4006
|
+
});
|
|
4007
|
+
let pipelineSegments;
|
|
4008
|
+
if (pipelineMarker && pipeline.length > 0) {
|
|
4009
|
+
pipelineSegments = parsePipelineOutput(result.stdout, pipeline, pipelineMarker);
|
|
3359
4010
|
}
|
|
3360
|
-
const result = await sshExec(conn, finalCmd, proxy, stdin !== void 0 ? { stdin } : void 0);
|
|
3361
4011
|
let serverName = serverId;
|
|
3362
4012
|
try {
|
|
3363
|
-
const { data } = await supabase.from("ssh_server").select("name").eq("id", serverId).
|
|
4013
|
+
const { data } = await supabase.from("ssh_server").select("name").eq("id", serverId).maybeSingle();
|
|
3364
4014
|
if (data?.name) serverName = data.name;
|
|
3365
4015
|
} catch {
|
|
3366
4016
|
}
|
|
3367
|
-
return { serverId, serverName, os, shell, result };
|
|
4017
|
+
return { serverId, serverName, os, shell, result, pipelineSegments };
|
|
4018
|
+
};
|
|
4019
|
+
const renderOne = (r) => {
|
|
4020
|
+
if (r.iterations && r.iterations.length > 0) {
|
|
4021
|
+
return renderRepeatResult(r.iterations, repeatOutput, format);
|
|
4022
|
+
}
|
|
4023
|
+
if (r.pipelineSegments) {
|
|
4024
|
+
return renderPipelineSegments(r.pipelineSegments, r.result, format);
|
|
4025
|
+
}
|
|
4026
|
+
const output = [`Exit code: ${r.result.exitCode} (os: ${r.os}, shell: ${r.shell})`];
|
|
4027
|
+
let stdoutText = r.result.stdout;
|
|
4028
|
+
if (format === "ndjson" && stdoutText) {
|
|
4029
|
+
const parsed = tryParseNdjsonFromCommand(command, stdoutText);
|
|
4030
|
+
if (parsed) stdoutText = parsed;
|
|
4031
|
+
}
|
|
4032
|
+
if (stdoutText) output.push(`--- stdout ---
|
|
4033
|
+
${stdoutText}`);
|
|
4034
|
+
if (r.result.stderr) output.push(`--- stderr ---
|
|
4035
|
+
${r.result.stderr}`);
|
|
4036
|
+
return output.join("\n");
|
|
3368
4037
|
};
|
|
3369
4038
|
if (targetIds.length === 1) {
|
|
3370
|
-
const
|
|
3371
|
-
|
|
3372
|
-
if (result.stdout) output.push(`--- stdout ---
|
|
3373
|
-
${result.stdout}`);
|
|
3374
|
-
if (result.stderr) output.push(`--- stderr ---
|
|
3375
|
-
${result.stderr}`);
|
|
3376
|
-
return { content: [{ type: "text", text: output.join("\n") }] };
|
|
4039
|
+
const r = await runOne(targetIds[0]);
|
|
4040
|
+
return { content: [{ type: "text", text: renderOne(r) }] };
|
|
3377
4041
|
}
|
|
3378
4042
|
const allResults = [];
|
|
3379
4043
|
for (let i = 0; i < targetIds.length; i += parallelism) {
|
|
@@ -3397,10 +4061,8 @@ ${result.stderr}`);
|
|
|
3397
4061
|
if (r.result.exitCode === 0) okCount++;
|
|
3398
4062
|
else failCount++;
|
|
3399
4063
|
const header = `=== ${r.serverName} (${r.os}, exit ${r.result.exitCode}) ===`;
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
if (r.result.stderr) parts.push(`[stderr] ${r.result.stderr.trimEnd()}`);
|
|
3403
|
-
sections.push(parts.join("\n"));
|
|
4064
|
+
sections.push(`${header}
|
|
4065
|
+
${renderOne(r)}`);
|
|
3404
4066
|
}
|
|
3405
4067
|
sections.push(`--- fan-out summary: ${okCount} ok / ${failCount} failed across ${allResults.length} servers (parallelism ${parallelism}) ---`);
|
|
3406
4068
|
return { content: [{ type: "text", text: sections.join("\n\n") }] };
|
|
@@ -3502,6 +4164,46 @@ ${result.stderr}`);
|
|
|
3502
4164
|
}
|
|
3503
4165
|
case "sftp-write": {
|
|
3504
4166
|
const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
|
|
4167
|
+
if (a.sourceRemote && typeof a.sourceRemote === "object") {
|
|
4168
|
+
const srcRaw = a.sourceRemote;
|
|
4169
|
+
const srcBucket = typeof srcRaw.bucket === "string" && srcRaw.bucket ? String(srcRaw.bucket) : "";
|
|
4170
|
+
const srcServerId = typeof srcRaw.serverId === "string" && srcRaw.serverId ? String(srcRaw.serverId) : "";
|
|
4171
|
+
const srcPath = String(srcRaw.path || "");
|
|
4172
|
+
if (!srcPath) return { content: [{ type: "text", text: "Error: sourceRemote.path is required" }] };
|
|
4173
|
+
if (!srcBucket && !srcServerId) return { content: [{ type: "text", text: "Error: sourceRemote needs `serverId` or `bucket`" }] };
|
|
4174
|
+
if (srcBucket && srcServerId) return { content: [{ type: "text", text: "Error: sourceRemote can have either `serverId` or `bucket`, not both" }] };
|
|
4175
|
+
const dstPath = String(a.path || "");
|
|
4176
|
+
if (!dstPath) return { content: [{ type: "text", text: "Error: target `path` is required" }] };
|
|
4177
|
+
let srcEp;
|
|
4178
|
+
if (srcBucket) {
|
|
4179
|
+
const k = r2Key(srcPath);
|
|
4180
|
+
if (!k) return { content: [{ type: "text", text: "Error: sourceRemote.path resolves to empty key" }] };
|
|
4181
|
+
srcEp = { kind: "r2", bucket: srcBucket, key: k };
|
|
4182
|
+
} else {
|
|
4183
|
+
const { conn: conn2, proxy: proxy2 } = await getServerConnection(srcServerId);
|
|
4184
|
+
srcEp = { kind: "ssh", opts: conn2, proxy: proxy2, path: srcPath };
|
|
4185
|
+
}
|
|
4186
|
+
let dstEp;
|
|
4187
|
+
if (bucket) {
|
|
4188
|
+
const k = r2Key(dstPath);
|
|
4189
|
+
if (!k) return { content: [{ type: "text", text: "Error: target path resolves to empty key" }] };
|
|
4190
|
+
dstEp = { kind: "r2", bucket, key: k };
|
|
4191
|
+
} else if (a.serverId) {
|
|
4192
|
+
const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
|
|
4193
|
+
dstEp = { kind: "ssh", opts: conn2, proxy: proxy2, path: dstPath };
|
|
4194
|
+
} else {
|
|
4195
|
+
return { content: [{ type: "text", text: "Error: target needs `serverId` or `bucket`" }] };
|
|
4196
|
+
}
|
|
4197
|
+
try {
|
|
4198
|
+
const { bytes, elapsedMs, mode } = await sftpCopy(srcEp, dstEp);
|
|
4199
|
+
const srcLabel = srcEp.kind === "r2" ? `r2://${srcEp.bucket}/${srcEp.key}` : `ssh:${srcPath}`;
|
|
4200
|
+
const dstLabel = dstEp.kind === "r2" ? `r2://${dstEp.bucket}/${dstEp.key}` : `ssh:${dstPath}`;
|
|
4201
|
+
const mbps = bytes > 0 && elapsedMs > 0 ? bytes / (1024 * 1024) / (elapsedMs / 1e3) : 0;
|
|
4202
|
+
return { content: [{ type: "text", text: `Copied ${formatBytes(bytes)} from ${srcLabel} \u2192 ${dstLabel} via ${mode} in ${(elapsedMs / 1e3).toFixed(2)}s${mbps > 0 ? ` (${mbps.toFixed(1)} MB/s)` : ""}` }] };
|
|
4203
|
+
} catch (e) {
|
|
4204
|
+
return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }] };
|
|
4205
|
+
}
|
|
4206
|
+
}
|
|
3505
4207
|
let fileMode;
|
|
3506
4208
|
if (typeof a.mode === "number" && Number.isFinite(a.mode)) fileMode = a.mode & 4095;
|
|
3507
4209
|
else if (typeof a.mode === "string" && a.mode) {
|
|
@@ -3564,14 +4266,51 @@ ${result.stderr}`);
|
|
|
3564
4266
|
case "sftp-delete": {
|
|
3565
4267
|
const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
|
|
3566
4268
|
const recursive = a.recursive === true;
|
|
4269
|
+
const dryRun = a.dryRun === true;
|
|
4270
|
+
const confirmAbove = a.confirmAbove !== void 0 ? Math.max(0, Number(a.confirmAbove)) : void 0;
|
|
4271
|
+
const confirmCount = a.confirmCount !== void 0 ? Number(a.confirmCount) : void 0;
|
|
4272
|
+
const guard = (count, sizeBytes, where) => {
|
|
4273
|
+
if (confirmAbove !== void 0 && count > confirmAbove) {
|
|
4274
|
+
if (confirmCount === count) return null;
|
|
4275
|
+
return {
|
|
4276
|
+
content: [{
|
|
4277
|
+
type: "text",
|
|
4278
|
+
text: `Refusing to delete ${count} item(s) (${formatBytes(sizeBytes)}) under ${where}: exceeds confirmAbove=${confirmAbove}. To proceed, re-issue the call with confirmCount: ${count}.`
|
|
4279
|
+
}]
|
|
4280
|
+
};
|
|
4281
|
+
}
|
|
4282
|
+
return null;
|
|
4283
|
+
};
|
|
3567
4284
|
if (bucket) {
|
|
3568
4285
|
const key = r2Key(String(a.path));
|
|
3569
4286
|
if (!key) return { content: [{ type: "text", text: "Error: path is required" }] };
|
|
3570
4287
|
try {
|
|
3571
4288
|
if (recursive) {
|
|
4289
|
+
if (dryRun || confirmAbove !== void 0) {
|
|
4290
|
+
const listed = await r2List(bucket, key, { recursive: true, maxResults: 5e4 });
|
|
4291
|
+
const objects = listed.filter((e) => !e.isPrefix);
|
|
4292
|
+
const totalSize = objects.reduce((s, o) => s + (o.size || 0), 0);
|
|
4293
|
+
if (dryRun) {
|
|
4294
|
+
const sample = objects.slice(0, 5).map((o) => ` - r2://${bucket}/${o.key} (${formatBytes(o.size || 0)})`);
|
|
4295
|
+
return { content: [{ type: "text", text: `[dry-run] Would delete ${objects.length} object(s) totalling ${formatBytes(totalSize)} under r2://${bucket}/${key}${objects.length > 5 ? `
|
|
4296
|
+
${sample.join("\n")}
|
|
4297
|
+
... and ${objects.length - 5} more` : objects.length ? `
|
|
4298
|
+
${sample.join("\n")}` : ""}` }] };
|
|
4299
|
+
}
|
|
4300
|
+
const block = guard(objects.length, totalSize, `r2://${bucket}/${key}`);
|
|
4301
|
+
if (block) return block;
|
|
4302
|
+
}
|
|
3572
4303
|
const deleted = await r2DeletePrefix(bucket, key);
|
|
3573
4304
|
return { content: [{ type: "text", text: `Deleted ${deleted} object(s) under r2://${bucket}/${key}` }] };
|
|
3574
4305
|
}
|
|
4306
|
+
if (dryRun) {
|
|
4307
|
+
try {
|
|
4308
|
+
const head = await getR2Client().send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
|
|
4309
|
+
return { content: [{ type: "text", text: `[dry-run] Would delete r2://${bucket}/${key} (${formatBytes(head.ContentLength || 0)})` }] };
|
|
4310
|
+
} catch {
|
|
4311
|
+
return { content: [{ type: "text", text: `[dry-run] r2://${bucket}/${key} not found (delete would 404)` }] };
|
|
4312
|
+
}
|
|
4313
|
+
}
|
|
3575
4314
|
await r2DeleteObject(bucket, key);
|
|
3576
4315
|
return { content: [{ type: "text", text: `Deleted r2://${bucket}/${key}` }] };
|
|
3577
4316
|
} catch (e) {
|
|
@@ -3579,6 +4318,25 @@ ${result.stderr}`);
|
|
|
3579
4318
|
}
|
|
3580
4319
|
}
|
|
3581
4320
|
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
4321
|
+
if (recursive && (dryRun || confirmAbove !== void 0)) {
|
|
4322
|
+
const listed = await sftpReaddir(conn, String(a.path), proxy, {
|
|
4323
|
+
recursive: true,
|
|
4324
|
+
maxResults: 5e4
|
|
4325
|
+
});
|
|
4326
|
+
const files = listed.entries.filter((e) => e.kind === "-");
|
|
4327
|
+
const dirs = listed.entries.filter((e) => e.kind === "d");
|
|
4328
|
+
const totalSize = files.reduce((s, f) => s + (f.size || 0), 0);
|
|
4329
|
+
if (dryRun) {
|
|
4330
|
+
const sample = files.slice(0, 5).map((f) => ` - ${f.path} (${formatBytes(f.size || 0)})`);
|
|
4331
|
+
return { content: [{ type: "text", text: `[dry-run] Would delete ${files.length} file(s) + ${dirs.length} directory(ies) totalling ${formatBytes(totalSize)} under ${a.path}${files.length ? `
|
|
4332
|
+
${sample.join("\n")}${files.length > 5 ? `
|
|
4333
|
+
... and ${files.length - 5} more` : ""}` : ""}` }] };
|
|
4334
|
+
}
|
|
4335
|
+
const block = guard(files.length + dirs.length, totalSize, String(a.path));
|
|
4336
|
+
if (block) return block;
|
|
4337
|
+
} else if (dryRun) {
|
|
4338
|
+
return { content: [{ type: "text", text: `[dry-run] Would delete ${a.path} (single file/dir; pass recursive: true to preview tree contents)` }] };
|
|
4339
|
+
}
|
|
3582
4340
|
const result = await sftpDelete(conn, String(a.path), proxy, { recursive });
|
|
3583
4341
|
return { content: [{ type: "text", text: result }] };
|
|
3584
4342
|
}
|
|
@@ -3588,75 +4346,136 @@ ${result.stderr}`);
|
|
|
3588
4346
|
const composeOnly = a.composeOnly === true;
|
|
3589
4347
|
const groupByProject = a.groupByProject === true;
|
|
3590
4348
|
const nameFiltersRaw = Array.isArray(a.nameFilter) ? a.nameFilter.map((v) => String(v).trim()).filter(Boolean) : typeof a.nameFilter === "string" && a.nameFilter.trim() ? [a.nameFilter.trim()] : [];
|
|
4349
|
+
const targetIds = Array.isArray(a.serverIds) && a.serverIds.length > 0 ? a.serverIds.map(String) : a.serverId ? [String(a.serverId)] : [];
|
|
4350
|
+
if (targetIds.length === 0) {
|
|
4351
|
+
return { content: [{ type: "text", text: "Error: pass either `serverId` or `serverIds[]`" }] };
|
|
4352
|
+
}
|
|
4353
|
+
const parallelism = Math.max(1, Math.min(20, Number(a.parallelism) || 5));
|
|
3591
4354
|
const filterArg = composeOnly ? ` --filter "label=com.docker.compose.project"` : "";
|
|
3592
4355
|
const cmd = `docker ps -a${filterArg} --format '{{json .}}'`;
|
|
3593
|
-
const
|
|
3594
|
-
|
|
3595
|
-
if (result.exitCode !== 0) {
|
|
3596
|
-
return { content: [{ type: "text", text: `Error: ${result.stderr || result.stdout}` }] };
|
|
3597
|
-
}
|
|
3598
|
-
const containers = [];
|
|
3599
|
-
for (const line of result.stdout.split("\n")) {
|
|
3600
|
-
const trimmed = line.trim();
|
|
3601
|
-
if (!trimmed) continue;
|
|
4356
|
+
const probeOne = async (serverId) => {
|
|
4357
|
+
let serverName = serverId.slice(0, 8);
|
|
3602
4358
|
try {
|
|
3603
|
-
|
|
3604
|
-
|
|
4359
|
+
const { conn, proxy } = await getServerConnection(serverId);
|
|
4360
|
+
const r = await sshExec(conn, cmd, proxy);
|
|
4361
|
+
const cached = KNOWN_SERVER_NAMES.get(serverId);
|
|
4362
|
+
if (cached) serverName = cached.name;
|
|
4363
|
+
if (r.exitCode !== 0) {
|
|
4364
|
+
return { serverId, serverName, rows: [], error: (r.stderr || r.stdout || "exit " + r.exitCode).trim().slice(0, 200) };
|
|
4365
|
+
}
|
|
4366
|
+
const rows = [];
|
|
4367
|
+
for (const line of r.stdout.split("\n")) {
|
|
4368
|
+
const t = line.trim();
|
|
4369
|
+
if (!t) continue;
|
|
4370
|
+
try {
|
|
4371
|
+
rows.push(JSON.parse(t));
|
|
4372
|
+
} catch {
|
|
4373
|
+
}
|
|
4374
|
+
}
|
|
4375
|
+
for (const c of rows) {
|
|
4376
|
+
const m = (c.Labels || "").match(/com\.docker\.compose\.project=([^,]+)/);
|
|
4377
|
+
c.Project = m ? m[1] : "";
|
|
4378
|
+
const status = c.Status || "";
|
|
4379
|
+
if (/\(unhealthy\)/i.test(status)) c.Health = "unhealthy";
|
|
4380
|
+
else if (/\(health: starting\)|\(starting\)/i.test(status)) c.Health = "starting";
|
|
4381
|
+
else if (/\(healthy\)/i.test(status)) c.Health = "healthy";
|
|
4382
|
+
else if (/^Up\b/i.test(status)) c.Health = "no-check";
|
|
4383
|
+
else c.Health = "";
|
|
4384
|
+
}
|
|
4385
|
+
rememberContainers(serverId, rows.map((r2) => r2.Names || "").filter(Boolean));
|
|
4386
|
+
return { serverId, serverName, rows };
|
|
4387
|
+
} catch (err) {
|
|
4388
|
+
return { serverId, serverName, rows: [], error: err instanceof Error ? err.message.slice(0, 200) : String(err).slice(0, 200) };
|
|
3605
4389
|
}
|
|
4390
|
+
};
|
|
4391
|
+
const perServer = [];
|
|
4392
|
+
for (let i = 0; i < targetIds.length; i += parallelism) {
|
|
4393
|
+
const batch = targetIds.slice(i, i + parallelism);
|
|
4394
|
+
const chunk = await Promise.all(batch.map(probeOne));
|
|
4395
|
+
perServer.push(...chunk);
|
|
3606
4396
|
}
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
c.Project = m ? m[1] : "";
|
|
3610
|
-
const status = c.Status || "";
|
|
3611
|
-
if (/\(unhealthy\)/i.test(status)) c.Health = "unhealthy";
|
|
3612
|
-
else if (/\(health: starting\)|\(starting\)/i.test(status)) c.Health = "starting";
|
|
3613
|
-
else if (/\(healthy\)/i.test(status)) c.Health = "healthy";
|
|
3614
|
-
else if (/^Up\b/i.test(status)) c.Health = "no-check";
|
|
3615
|
-
else c.Health = "";
|
|
3616
|
-
}
|
|
3617
|
-
let filtered = containers;
|
|
3618
|
-
if (nameFiltersRaw.length > 0) {
|
|
4397
|
+
const applyFilter = (rows) => {
|
|
4398
|
+
if (nameFiltersRaw.length === 0) return rows;
|
|
3619
4399
|
const matchers = nameFiltersRaw.map((raw) => {
|
|
3620
4400
|
if (raw.includes("*") || raw.includes("?")) return { kind: "glob", re: globToRegExp(raw) };
|
|
3621
4401
|
return { kind: "sub", lower: raw.toLowerCase() };
|
|
3622
4402
|
});
|
|
3623
|
-
|
|
4403
|
+
return rows.filter((c) => {
|
|
3624
4404
|
const name2 = c.Names || "";
|
|
3625
4405
|
const lower = name2.toLowerCase();
|
|
3626
4406
|
return matchers.some((m) => m.kind === "glob" ? m.re.test(name2) : lower.includes(m.lower));
|
|
3627
4407
|
});
|
|
3628
|
-
}
|
|
3629
|
-
|
|
4408
|
+
};
|
|
4409
|
+
const sortRows = (rows) => rows.sort((x, y) => {
|
|
3630
4410
|
const px = x.Project || "~";
|
|
3631
4411
|
const py = y.Project || "~";
|
|
3632
4412
|
const p = px.localeCompare(py);
|
|
3633
4413
|
return p !== 0 ? p : (x.Names || "").localeCompare(y.Names || "");
|
|
3634
4414
|
});
|
|
4415
|
+
const fmtRow = (r) => `${(r.Names || "").padEnd(36)} ${(r.Image || "").padEnd(40)} ${(r.Status || "").padEnd(24)} ${(r.Health || "").padEnd(10)} ${(r.Ports || "").padEnd(40)}`;
|
|
4416
|
+
if (targetIds.length === 1) {
|
|
4417
|
+
const only = perServer[0];
|
|
4418
|
+
if (only.error) return { content: [{ type: "text", text: `Error: ${only.error}` }] };
|
|
4419
|
+
const filtered = sortRows(applyFilter(only.rows));
|
|
4420
|
+
if (format === "json") {
|
|
4421
|
+
const text2 = filtered.map((c) => JSON.stringify(c)).join("\n") || "(no containers)";
|
|
4422
|
+
return { content: [{ type: "text", text: text2 }] };
|
|
4423
|
+
}
|
|
4424
|
+
if (groupByProject) {
|
|
4425
|
+
const groups = /* @__PURE__ */ new Map();
|
|
4426
|
+
for (const c of filtered) {
|
|
4427
|
+
const key = c.Project || "(no compose project)";
|
|
4428
|
+
const list = groups.get(key);
|
|
4429
|
+
if (list) list.push(c);
|
|
4430
|
+
else groups.set(key, [c]);
|
|
4431
|
+
}
|
|
4432
|
+
const out = [];
|
|
4433
|
+
for (const [project, rows] of groups) {
|
|
4434
|
+
out.push(`=== ${project} (${rows.length}) ===`);
|
|
4435
|
+
for (const r of rows) out.push(" " + fmtRow(r));
|
|
4436
|
+
out.push("");
|
|
4437
|
+
}
|
|
4438
|
+
return { content: [{ type: "text", text: out.join("\n").trimEnd() || "(no containers)" }] };
|
|
4439
|
+
}
|
|
4440
|
+
const header = `${"NAMES".padEnd(36)} ${"IMAGE".padEnd(40)} ${"STATUS".padEnd(24)} ${"HEALTH".padEnd(10)} ${"PORTS".padEnd(40)} PROJECT`;
|
|
4441
|
+
const body = filtered.map((r) => `${fmtRow(r)} ${r.Project || ""}`);
|
|
4442
|
+
const text = filtered.length === 0 ? "(no containers match)" : [header, ...body].join("\n");
|
|
4443
|
+
return { content: [{ type: "text", text }] };
|
|
4444
|
+
}
|
|
3635
4445
|
if (format === "json") {
|
|
3636
|
-
const
|
|
3637
|
-
|
|
4446
|
+
const lines = [];
|
|
4447
|
+
for (const ps of perServer) {
|
|
4448
|
+
if (ps.error) {
|
|
4449
|
+
lines.push(JSON.stringify({ Server: ps.serverName, Error: ps.error }));
|
|
4450
|
+
continue;
|
|
4451
|
+
}
|
|
4452
|
+
const filtered = sortRows(applyFilter(ps.rows));
|
|
4453
|
+
for (const c of filtered) lines.push(JSON.stringify({ Server: ps.serverName, ...c }));
|
|
4454
|
+
}
|
|
4455
|
+
return { content: [{ type: "text", text: lines.join("\n") || "(no containers)" }] };
|
|
3638
4456
|
}
|
|
3639
|
-
const
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
else groups.set(key, [c]);
|
|
4457
|
+
const sections = [];
|
|
4458
|
+
let totalRows = 0;
|
|
4459
|
+
let serversWithRows = 0;
|
|
4460
|
+
for (const ps of perServer) {
|
|
4461
|
+
if (ps.error) {
|
|
4462
|
+
sections.push(`=== ${ps.serverName} === !! ${ps.error}`);
|
|
4463
|
+
continue;
|
|
3647
4464
|
}
|
|
3648
|
-
const
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
out.push("");
|
|
4465
|
+
const filtered = sortRows(applyFilter(ps.rows));
|
|
4466
|
+
if (filtered.length === 0) {
|
|
4467
|
+
sections.push(`=== ${ps.serverName} === (no containers match)`);
|
|
4468
|
+
continue;
|
|
3653
4469
|
}
|
|
3654
|
-
|
|
4470
|
+
serversWithRows++;
|
|
4471
|
+
totalRows += filtered.length;
|
|
4472
|
+
sections.push(`=== ${ps.serverName} (${filtered.length} container(s)) ===`);
|
|
4473
|
+
const header = `${"NAMES".padEnd(36)} ${"IMAGE".padEnd(40)} ${"STATUS".padEnd(24)} ${"HEALTH".padEnd(10)} ${"PORTS".padEnd(40)} PROJECT`;
|
|
4474
|
+
sections.push(header);
|
|
4475
|
+
for (const r of filtered) sections.push(`${fmtRow(r)} ${r.Project || ""}`);
|
|
3655
4476
|
}
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
const text = filtered.length === 0 ? "(no containers match)" : [header, ...body].join("\n");
|
|
3659
|
-
return { content: [{ type: "text", text }] };
|
|
4477
|
+
sections.push(`--- fan-out summary: ${totalRows} container(s) across ${serversWithRows}/${perServer.length} server(s) ---`);
|
|
4478
|
+
return { content: [{ type: "text", text: sections.join("\n") }] };
|
|
3660
4479
|
}
|
|
3661
4480
|
case "docker-logs": {
|
|
3662
4481
|
const rawNames = Array.isArray(a.containerName) ? a.containerName.map(String) : [String(a.containerName)];
|
|
@@ -3674,37 +4493,76 @@ ${result.stderr}`);
|
|
|
3674
4493
|
}
|
|
3675
4494
|
const sinceArg = sinceRaw ? ` --since ${posixQuote(sinceRaw)}` : "";
|
|
3676
4495
|
const grepSuffix = grepRaw ? ` | grep -i -E ${posixQuote(grepRaw)} --line-buffered` : "";
|
|
3677
|
-
const
|
|
4496
|
+
const targetIds = Array.isArray(a.serverIds) && a.serverIds.length > 0 ? a.serverIds.map(String) : a.serverId ? [String(a.serverId)] : [];
|
|
4497
|
+
if (targetIds.length === 0) {
|
|
4498
|
+
return { content: [{ type: "text", text: "Error: pass either `serverId` or `serverIds[]`" }] };
|
|
4499
|
+
}
|
|
4500
|
+
const parallelism = Math.max(1, Math.min(20, Number(a.parallelism) || 5));
|
|
3678
4501
|
const wallSeconds = followSeconds > 0 ? followSeconds : 30;
|
|
3679
|
-
conn.timeout = (wallSeconds + 10) * 1e3;
|
|
3680
4502
|
const followFlag = followSeconds > 0 ? " -f" : "";
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
const acceptable2 = result2.exitCode === 0 || result2.exitCode === 124 || result2.exitCode === 130 || result2.exitCode === 143 || !!grepRaw && result2.exitCode === 1;
|
|
3687
|
-
if (!acceptable2 && !result2.stdout) {
|
|
3688
|
-
return { content: [{ type: "text", text: `Error (exit ${result2.exitCode}): ${result2.stderr || "(no output)"}` }] };
|
|
4503
|
+
const buildCmd = () => {
|
|
4504
|
+
if (containers.length === 1) {
|
|
4505
|
+
const c = containers[0];
|
|
4506
|
+
const inner = `docker logs${followFlag} --tail ${tailArg}${sinceArg} ${c} 2>&1${grepSuffix}`;
|
|
4507
|
+
return `timeout --signal=INT ${wallSeconds} sh -c ${posixQuote(inner)}`;
|
|
3689
4508
|
}
|
|
3690
|
-
const
|
|
4509
|
+
const subShells = containers.map((c) => {
|
|
4510
|
+
const inner = `docker logs${followFlag} --tail ${tailArg}${sinceArg} ${c} 2>&1${grepSuffix}`;
|
|
4511
|
+
return `(${inner} | sed -u -e ${posixQuote(`s/^/[${c}] /`)})`;
|
|
4512
|
+
});
|
|
4513
|
+
const innerCmd = subShells.join(" & ") + " & wait";
|
|
4514
|
+
return `timeout --signal=INT ${wallSeconds} sh -c ${posixQuote(innerCmd)}`;
|
|
4515
|
+
};
|
|
4516
|
+
const cmd = buildCmd();
|
|
4517
|
+
const probeOne = async (serverId) => {
|
|
4518
|
+
const { conn, proxy } = await getServerConnection(serverId);
|
|
4519
|
+
conn.timeout = (wallSeconds + 10) * 1e3;
|
|
4520
|
+
const result = await sshExec(conn, cmd, proxy);
|
|
4521
|
+
const cached = KNOWN_SERVER_NAMES.get(serverId);
|
|
4522
|
+
return { serverId, serverName: cached?.name || serverId.slice(0, 8), result };
|
|
4523
|
+
};
|
|
4524
|
+
const acceptableCode = (code) => code === 0 || code === 124 || code === 130 || code === 143 || !!grepRaw && code === 1;
|
|
4525
|
+
if (targetIds.length === 1) {
|
|
4526
|
+
const r = await probeOne(targetIds[0]);
|
|
4527
|
+
const result = r.result;
|
|
4528
|
+
const combined = `${result.stdout}
|
|
4529
|
+
${result.stderr}`;
|
|
4530
|
+
const noSuchMatch = /No such container:\s*(\S+)/i.exec(combined);
|
|
4531
|
+
const fuzzyHint = () => {
|
|
4532
|
+
if (!noSuchMatch) return "";
|
|
4533
|
+
const known = getKnownContainers(targetIds[0]);
|
|
4534
|
+
if (!known?.length) return "";
|
|
4535
|
+
const hits = suggestSimilar(noSuchMatch[1], known);
|
|
4536
|
+
return hits.length ? `
|
|
4537
|
+
(hint) Did you mean: ${hits.join(", ")}?` : "";
|
|
4538
|
+
};
|
|
4539
|
+
if (!acceptableCode(result.exitCode) && !result.stdout) {
|
|
4540
|
+
return { content: [{ type: "text", text: `Error (exit ${result.exitCode}): ${result.stderr || "(no output)"}${fuzzyHint()}` }] };
|
|
4541
|
+
}
|
|
4542
|
+
const note = result.exitCode === 124 ? `
|
|
3691
4543
|
(note: command exceeded ${wallSeconds}s wall budget; partial output)` : "";
|
|
3692
|
-
return { content: [{ type: "text", text: (
|
|
4544
|
+
return { content: [{ type: "text", text: (result.stdout || "(no log lines matched)") + note + fuzzyHint() }] };
|
|
3693
4545
|
}
|
|
3694
|
-
const
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
const cmd = `timeout --signal=INT ${wallSeconds} sh -c ${posixQuote(innerCmd)}`;
|
|
3700
|
-
const result = await sshExec(conn, cmd, proxy);
|
|
3701
|
-
const acceptable = result.exitCode === 0 || result.exitCode === 124 || result.exitCode === 130 || result.exitCode === 143 || !!grepRaw && result.exitCode === 1;
|
|
3702
|
-
if (!acceptable && !result.stdout) {
|
|
3703
|
-
return { content: [{ type: "text", text: `Error (exit ${result.exitCode}): ${result.stderr || "(no output)"}` }] };
|
|
4546
|
+
const perServer = [];
|
|
4547
|
+
for (let i = 0; i < targetIds.length; i += parallelism) {
|
|
4548
|
+
const batch = targetIds.slice(i, i + parallelism);
|
|
4549
|
+
const chunk = await Promise.all(batch.map(probeOne));
|
|
4550
|
+
perServer.push(...chunk);
|
|
3704
4551
|
}
|
|
3705
|
-
const
|
|
3706
|
-
|
|
3707
|
-
|
|
4552
|
+
const sections = [];
|
|
4553
|
+
let okCount = 0;
|
|
4554
|
+
for (const ps of perServer) {
|
|
4555
|
+
if (acceptableCode(ps.result.exitCode) || ps.result.stdout) {
|
|
4556
|
+
okCount++;
|
|
4557
|
+
const note = ps.result.exitCode === 124 ? ` (timed out after ${wallSeconds}s; partial)` : "";
|
|
4558
|
+
sections.push(`=== ${ps.serverName}${note} ===`);
|
|
4559
|
+
sections.push(ps.result.stdout || "(no log lines matched)");
|
|
4560
|
+
} else {
|
|
4561
|
+
sections.push(`=== ${ps.serverName} === !! exit ${ps.result.exitCode}: ${ps.result.stderr.trim().slice(0, 200) || "(no output)"}`);
|
|
4562
|
+
}
|
|
4563
|
+
}
|
|
4564
|
+
sections.push(`--- fan-out summary: ${okCount}/${perServer.length} server(s) returned logs ---`);
|
|
4565
|
+
return { content: [{ type: "text", text: sections.join("\n") }] };
|
|
3708
4566
|
}
|
|
3709
4567
|
case "docker-exec": {
|
|
3710
4568
|
const container = String(a.container).replace(/[^a-zA-Z0-9._-]/g, "");
|
|
@@ -3729,8 +4587,20 @@ ${result.stderr}`);
|
|
|
3729
4587
|
const output = [`Exit code: ${result.exitCode}`];
|
|
3730
4588
|
if (result.stdout) output.push(`--- stdout ---
|
|
3731
4589
|
${result.stdout}`);
|
|
3732
|
-
if (result.stderr)
|
|
3733
|
-
|
|
4590
|
+
if (result.stderr) {
|
|
4591
|
+
let stderrOut = result.stderr;
|
|
4592
|
+
const noSuch = /No such container:\s*(\S+)/i.exec(result.stderr);
|
|
4593
|
+
if (noSuch) {
|
|
4594
|
+
const known = getKnownContainers(String(a.serverId));
|
|
4595
|
+
if (known && known.length) {
|
|
4596
|
+
const hits = suggestSimilar(noSuch[1], known);
|
|
4597
|
+
if (hits.length) stderrOut += `
|
|
4598
|
+
(hint) Did you mean: ${hits.join(", ")}?`;
|
|
4599
|
+
}
|
|
4600
|
+
}
|
|
4601
|
+
output.push(`--- stderr ---
|
|
4602
|
+
${stderrOut}`);
|
|
4603
|
+
}
|
|
3734
4604
|
return { content: [{ type: "text", text: output.join("\n") }] };
|
|
3735
4605
|
}
|
|
3736
4606
|
case "docker-compose": {
|
|
@@ -3782,6 +4652,94 @@ ${result.stderr}`);
|
|
|
3782
4652
|
${result.stderr}`);
|
|
3783
4653
|
return { content: [{ type: "text", text: output.join("\n") }] };
|
|
3784
4654
|
}
|
|
4655
|
+
// ----- wait-for: poll until a condition holds (one tool, four types) -----
|
|
4656
|
+
case "wait-for": {
|
|
4657
|
+
const type = String(a.type || "");
|
|
4658
|
+
if (!["url", "container-health", "file-exists", "sftp-exists"].includes(type)) {
|
|
4659
|
+
return { content: [{ type: "text", text: "Error: type must be one of: url, container-health, file-exists, sftp-exists" }] };
|
|
4660
|
+
}
|
|
4661
|
+
const timeoutSeconds = Math.max(1, Math.min(1800, Number(a.timeout) || 120));
|
|
4662
|
+
const intervalSeconds = Math.max(1, Math.min(60, Number(a.intervalSeconds) || 3));
|
|
4663
|
+
const deadline = Date.now() + timeoutSeconds * 1e3;
|
|
4664
|
+
const trail = [];
|
|
4665
|
+
const check = async () => {
|
|
4666
|
+
if (type === "url") {
|
|
4667
|
+
const target = String(a.target || "");
|
|
4668
|
+
if (!target) return { ok: false, observed: "no target url" };
|
|
4669
|
+
const expectStatus = a.until !== void 0 ? Number(a.until) : 200;
|
|
4670
|
+
try {
|
|
4671
|
+
const ctrl = new AbortController();
|
|
4672
|
+
const t = setTimeout(() => ctrl.abort(), 1e4);
|
|
4673
|
+
const res = await fetch(target, { signal: ctrl.signal, redirect: "manual" });
|
|
4674
|
+
clearTimeout(t);
|
|
4675
|
+
const ok2 = res.status === expectStatus || expectStatus === 200 && res.status >= 200 && res.status < 300;
|
|
4676
|
+
return { ok: ok2, observed: `HTTP ${res.status}` };
|
|
4677
|
+
} catch (e) {
|
|
4678
|
+
return { ok: false, observed: e instanceof Error ? e.message : String(e) };
|
|
4679
|
+
}
|
|
4680
|
+
}
|
|
4681
|
+
if (type === "container-health") {
|
|
4682
|
+
const containerName = String(a.target || "").replace(/[^a-zA-Z0-9._-]/g, "");
|
|
4683
|
+
const until = String(a.until || "healthy");
|
|
4684
|
+
if (!containerName) return { ok: false, observed: "no container name" };
|
|
4685
|
+
if (!a.serverId) return { ok: false, observed: "serverId is required for container-health" };
|
|
4686
|
+
const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
|
|
4687
|
+
const cmd2 = `docker inspect --format '{{json .State}}' ${containerName} 2>&1`;
|
|
4688
|
+
const r2 = await sshExec(conn2, cmd2, proxy2);
|
|
4689
|
+
if (r2.exitCode !== 0) return { ok: false, observed: r2.stderr.trim().slice(0, 80) || "inspect failed" };
|
|
4690
|
+
try {
|
|
4691
|
+
const state = JSON.parse(r2.stdout.trim());
|
|
4692
|
+
const healthStatus = state.Health?.Status;
|
|
4693
|
+
if (until === "healthy") {
|
|
4694
|
+
return { ok: healthStatus === "healthy", observed: `Status=${state.Status} Health=${healthStatus || "no-check"}` };
|
|
4695
|
+
}
|
|
4696
|
+
return { ok: state.Status === until, observed: `Status=${state.Status}` };
|
|
4697
|
+
} catch (e) {
|
|
4698
|
+
return { ok: false, observed: "parse error: " + (e instanceof Error ? e.message : String(e)) };
|
|
4699
|
+
}
|
|
4700
|
+
}
|
|
4701
|
+
const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
|
|
4702
|
+
const path = String(a.target || a.path || "");
|
|
4703
|
+
if (!path) return { ok: false, observed: "no path" };
|
|
4704
|
+
if (bucket) {
|
|
4705
|
+
try {
|
|
4706
|
+
await getR2Client().send(new HeadObjectCommand({ Bucket: bucket, Key: r2Key(path) }));
|
|
4707
|
+
return { ok: true, observed: `r2://${bucket}/${path} exists` };
|
|
4708
|
+
} catch {
|
|
4709
|
+
return { ok: false, observed: `r2://${bucket}/${path} not found` };
|
|
4710
|
+
}
|
|
4711
|
+
}
|
|
4712
|
+
if (!a.serverId) return { ok: false, observed: "serverId or bucket is required for file-exists" };
|
|
4713
|
+
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
4714
|
+
const safe = sanitizePath(path);
|
|
4715
|
+
const cmd = `test -e ${posixQuote(safe)} && echo OK || echo MISSING`;
|
|
4716
|
+
const r = await sshExec(conn, cmd, proxy);
|
|
4717
|
+
const ok = r.stdout.trim() === "OK";
|
|
4718
|
+
return { ok, observed: ok ? `${safe} exists` : `${safe} missing` };
|
|
4719
|
+
};
|
|
4720
|
+
let attempts = 0;
|
|
4721
|
+
let lastObserved = "";
|
|
4722
|
+
while (Date.now() < deadline) {
|
|
4723
|
+
attempts++;
|
|
4724
|
+
const r = await check();
|
|
4725
|
+
lastObserved = r.observed;
|
|
4726
|
+
trail.push(`[${attempts}] ${(/* @__PURE__ */ new Date()).toISOString().slice(11, 19)} ${r.ok ? "OK" : ".."} ${r.observed}`);
|
|
4727
|
+
if (r.ok) {
|
|
4728
|
+
return { content: [{ type: "text", text: `Condition met after ${attempts} attempt(s) in ${((Date.now() - (deadline - timeoutSeconds * 1e3)) / 1e3).toFixed(1)}s.
|
|
4729
|
+
Last observation: ${r.observed}
|
|
4730
|
+
|
|
4731
|
+
--- trail ---
|
|
4732
|
+
${trail.join("\n")}` }] };
|
|
4733
|
+
}
|
|
4734
|
+
if (Date.now() + intervalSeconds * 1e3 >= deadline) break;
|
|
4735
|
+
await new Promise((res) => setTimeout(res, intervalSeconds * 1e3));
|
|
4736
|
+
}
|
|
4737
|
+
return { content: [{ type: "text", text: `Timed out after ${timeoutSeconds}s without seeing condition (${attempts} probe(s)).
|
|
4738
|
+
Last observation: ${lastObserved}
|
|
4739
|
+
|
|
4740
|
+
--- trail ---
|
|
4741
|
+
${trail.join("\n")}` }] };
|
|
4742
|
+
}
|
|
3785
4743
|
// ----- Database -----
|
|
3786
4744
|
case "db-discover": {
|
|
3787
4745
|
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
@@ -3813,10 +4771,55 @@ ${result.stderr}`);
|
|
|
3813
4771
|
}
|
|
3814
4772
|
case "db-query": {
|
|
3815
4773
|
const query = String(a.query).trim();
|
|
4774
|
+
if (!query) return { content: [{ type: "text", text: "Error: query is required" }] };
|
|
3816
4775
|
assertSafeSql(query);
|
|
4776
|
+
const containerName = typeof a.containerName === "string" && a.containerName ? a.containerName.replace(/[^a-zA-Z0-9._-]/g, "") : "";
|
|
4777
|
+
const explicitEngine = a.engine === "mysql" || a.engine === "postgres" || a.engine === "mssql" ? a.engine : null;
|
|
4778
|
+
const engine = explicitEngine || (containerName ? "postgres" : "mysql");
|
|
4779
|
+
if (!containerName && engine === "mysql") {
|
|
4780
|
+
if (!a.sitePath) return { content: [{ type: "text", text: "Error: sitePath is required for mysql autodiscover (or pass containerName + engine for direct DB queries)" }] };
|
|
4781
|
+
const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
|
|
4782
|
+
const output2 = await execSiteMysql(conn2, String(a.sitePath), query, proxy2);
|
|
4783
|
+
return { content: [{ type: "text", text: output2 || "Query executed successfully (no output)" }] };
|
|
4784
|
+
}
|
|
4785
|
+
if (!containerName) {
|
|
4786
|
+
return { content: [{ type: "text", text: `Error: engine=${engine} requires containerName (the docker container running the DB server, e.g. "supabase-db" or "trigger-postgres")` }] };
|
|
4787
|
+
}
|
|
4788
|
+
const dbName = typeof a.dbName === "string" && a.dbName ? a.dbName.replace(/[^a-zA-Z0-9_-]/g, "") : "";
|
|
4789
|
+
const dbUser = typeof a.dbUser === "string" && a.dbUser ? a.dbUser.replace(/[^a-zA-Z0-9_-]/g, "") : "";
|
|
3817
4790
|
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
3818
|
-
|
|
3819
|
-
|
|
4791
|
+
let cmd;
|
|
4792
|
+
let stdinPayload;
|
|
4793
|
+
if (engine === "postgres") {
|
|
4794
|
+
const user = dbUser || "postgres";
|
|
4795
|
+
const db = dbName || "postgres";
|
|
4796
|
+
cmd = `docker exec -i ${containerName} psql -U ${user} -d ${db} -P pager=off`;
|
|
4797
|
+
stdinPayload = query.endsWith(";") ? query : `${query};`;
|
|
4798
|
+
} else if (engine === "mssql") {
|
|
4799
|
+
const user = dbUser || "sa";
|
|
4800
|
+
const dbPass = typeof a.dbPass === "string" ? a.dbPass : "";
|
|
4801
|
+
if (!dbPass) return { content: [{ type: "text", text: "Error: mssql requires dbPass (sqlcmd has no equivalent of pg trust auth)" }] };
|
|
4802
|
+
const dbArg = dbName ? `-d ${dbName} ` : "";
|
|
4803
|
+
cmd = `docker exec -i -e MSSQL_SA_PASSWORD=${posixQuote(dbPass)} ${containerName} /opt/mssql-tools18/bin/sqlcmd -S localhost -U ${user} -P "$MSSQL_SA_PASSWORD" -C ${dbArg}-h -1`;
|
|
4804
|
+
stdinPayload = query.endsWith(";") ? `${query}
|
|
4805
|
+
GO
|
|
4806
|
+
` : `${query};
|
|
4807
|
+
GO
|
|
4808
|
+
`;
|
|
4809
|
+
} else {
|
|
4810
|
+
const user = dbUser || "root";
|
|
4811
|
+
const db = dbName ? ` ${dbName}` : "";
|
|
4812
|
+
const dbPass = typeof a.dbPass === "string" ? a.dbPass : "";
|
|
4813
|
+
const passArg = dbPass ? `-p${posixQuote(dbPass)} ` : "";
|
|
4814
|
+
cmd = `docker exec -i ${containerName} mysql -t -u ${user} ${passArg}${db}`;
|
|
4815
|
+
stdinPayload = query.endsWith(";") ? query : `${query};`;
|
|
4816
|
+
}
|
|
4817
|
+
const result = await sshExec(conn, cmd, proxy, { stdin: stdinPayload });
|
|
4818
|
+
const output = result.stdout.trim() || result.stderr.trim() || "(no output)";
|
|
4819
|
+
if (result.exitCode !== 0 && !result.stdout) {
|
|
4820
|
+
return { content: [{ type: "text", text: `Error (exit ${result.exitCode}, ${engine}): ${output}` }] };
|
|
4821
|
+
}
|
|
4822
|
+
return { content: [{ type: "text", text: output }] };
|
|
3820
4823
|
}
|
|
3821
4824
|
// ----- Env Config -----
|
|
3822
4825
|
case "env-list": {
|