@linxin666/dsh-ssh 0.4.4 → 0.4.6
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/README.i18n.yaml +2 -2
- package/README.md +16 -2
- package/README.zh.md +16 -2
- package/lib/client.js +62 -14
- package/lib/client.js.map +1 -1
- package/lib/index.js +193 -41
- package/lib/types/client/api.d.ts +21 -10
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/engine/cluster.d.ts +2 -0
- package/lib/types/engine/connection-pool.d.ts +2 -2
- package/lib/types/engine/tunnel.d.ts +2 -0
- package/lib/types/engine.d.ts +2 -1
- package/lib/types/tool-surface.d.ts +71 -0
- package/package.json +18 -22
- package/src/client/api.ts +72 -15
- package/src/client/locales.ts +2 -2
- package/src/engine/cluster.ts +3 -1
- package/src/engine/connection-pool.ts +47 -2
- package/src/engine/tunnel.ts +74 -19
- package/src/engine.ts +3 -2
- package/src/index.ts +11 -3
- package/src/tool-surface.ts +95 -0
- package/src/tools.ts +15 -16
- package/lib/invariant.js +0 -6
- package/lib/types/invariant.d.ts +0 -3
- package/src/invariant.ts +0 -4
package/lib/index.js
CHANGED
|
@@ -1016,7 +1016,7 @@ function sweepPool(engine) {
|
|
|
1016
1016
|
* Run `fn` with a live client for `alias`, reconnecting (up to the
|
|
1017
1017
|
* attempt budget) when the connection broke mid-flight.
|
|
1018
1018
|
*/
|
|
1019
|
-
async function withClient(engine, alias, fn, attempts = 3) {
|
|
1019
|
+
async function withClient(engine, alias, fn, attempts = 3, signal) {
|
|
1020
1020
|
let lastError;
|
|
1021
1021
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
1022
1022
|
let record = engine.pool.get(alias);
|
|
@@ -1032,6 +1032,7 @@ async function withClient(engine, alias, fn, attempts = 3) {
|
|
|
1032
1032
|
return result;
|
|
1033
1033
|
} catch (error) {
|
|
1034
1034
|
lastError = error;
|
|
1035
|
+
if (signal?.aborted === true) throw error;
|
|
1035
1036
|
if (!record.broken) throw error;
|
|
1036
1037
|
disposeRecord(engine, alias, record);
|
|
1037
1038
|
} finally {
|
|
@@ -1041,16 +1042,33 @@ async function withClient(engine, alias, fn, attempts = 3) {
|
|
|
1041
1042
|
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
1042
1043
|
}
|
|
1043
1044
|
/** Run one command on `alias` (reusing the pooled connection). */
|
|
1044
|
-
async function execCommand(engine, alias, command, timeoutMs) {
|
|
1045
|
+
async function execCommand(engine, alias, command, timeoutMs, signal) {
|
|
1045
1046
|
const started = Date.now();
|
|
1046
1047
|
const budget = timeoutMs !== void 0 && timeoutMs > 0 ? timeoutMs : engine.opts.defaultExecTimeoutMs;
|
|
1047
1048
|
return withClient(engine, alias, async (client) => {
|
|
1048
1049
|
return await new Promise((resolve, reject) => {
|
|
1050
|
+
let aborted = signal !== void 0 && signal.aborted;
|
|
1051
|
+
let kill = () => {};
|
|
1052
|
+
const onAbort = () => {
|
|
1053
|
+
aborted = true;
|
|
1054
|
+
kill();
|
|
1055
|
+
};
|
|
1056
|
+
if (signal !== void 0 && !signal.aborted) signal.addEventListener("abort", onAbort, { once: true });
|
|
1049
1057
|
client.exec(command, (error, stream) => {
|
|
1050
1058
|
if (error !== void 0) {
|
|
1059
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1051
1060
|
reject(error);
|
|
1052
1061
|
return;
|
|
1053
1062
|
}
|
|
1063
|
+
kill = () => {
|
|
1064
|
+
try {
|
|
1065
|
+
stream.signal("KILL");
|
|
1066
|
+
} catch {}
|
|
1067
|
+
try {
|
|
1068
|
+
stream.close();
|
|
1069
|
+
} catch {}
|
|
1070
|
+
};
|
|
1071
|
+
if (aborted) kill();
|
|
1054
1072
|
const stdout = {
|
|
1055
1073
|
text: "",
|
|
1056
1074
|
truncated: false
|
|
@@ -1061,10 +1079,21 @@ async function execCommand(engine, alias, command, timeoutMs) {
|
|
|
1061
1079
|
};
|
|
1062
1080
|
let timedOut = false;
|
|
1063
1081
|
let settled = false;
|
|
1082
|
+
/** One cancelled outcome: the remote channel was killed on caller request. */
|
|
1083
|
+
const cancelledResult = () => ({
|
|
1084
|
+
success: false,
|
|
1085
|
+
exitCode: null,
|
|
1086
|
+
timedOut: false,
|
|
1087
|
+
stdout: stdout.text,
|
|
1088
|
+
stderr: stderr.text,
|
|
1089
|
+
durationMs: Date.now() - started,
|
|
1090
|
+
error: "command cancelled by the caller"
|
|
1091
|
+
});
|
|
1064
1092
|
const finish = () => {
|
|
1065
1093
|
if (settled) return;
|
|
1066
1094
|
settled = true;
|
|
1067
1095
|
clearTimeout(timer);
|
|
1096
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1068
1097
|
resolve({
|
|
1069
1098
|
success: false,
|
|
1070
1099
|
exitCode: null,
|
|
@@ -1091,6 +1120,11 @@ async function execCommand(engine, alias, command, timeoutMs) {
|
|
|
1091
1120
|
if (settled) return;
|
|
1092
1121
|
settled = true;
|
|
1093
1122
|
clearTimeout(timer);
|
|
1123
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1124
|
+
if (aborted) {
|
|
1125
|
+
resolve(cancelledResult());
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1094
1128
|
if (typeof code !== "number" && !timedOut) {
|
|
1095
1129
|
reject(/* @__PURE__ */ new Error("ssh: connection lost mid-flight (channel closed without an exit status)"));
|
|
1096
1130
|
return;
|
|
@@ -1108,6 +1142,11 @@ async function execCommand(engine, alias, command, timeoutMs) {
|
|
|
1108
1142
|
if (settled) return;
|
|
1109
1143
|
settled = true;
|
|
1110
1144
|
clearTimeout(timer);
|
|
1145
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1146
|
+
if (aborted) {
|
|
1147
|
+
resolve(cancelledResult());
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1111
1150
|
reject(streamError);
|
|
1112
1151
|
});
|
|
1113
1152
|
});
|
|
@@ -1413,6 +1452,24 @@ function fastGet(sftp, src, dst, concurrency, onProgress) {
|
|
|
1413
1452
|
* to a pooled connection so an idle sweep never closes it, with per-tunnel
|
|
1414
1453
|
* socket tracking and stop (single / alias-scoped / all).
|
|
1415
1454
|
*/
|
|
1455
|
+
/** Destroy one socket or channel stream, tolerating an already-dead peer. */
|
|
1456
|
+
function destroyQuietly(target) {
|
|
1457
|
+
try {
|
|
1458
|
+
target.destroy();
|
|
1459
|
+
} catch {}
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Tear down one tunnel because the SSH link behind it is gone. Idempotent: the
|
|
1463
|
+
* tunnel registry is the guard, so a socket callback or a link-death hook that
|
|
1464
|
+
* arrives after a manual stop does nothing instead of tearing down twice, and
|
|
1465
|
+
* no teardown error can escape into the net connection handler — an exception
|
|
1466
|
+
* there terminates the whole host process.
|
|
1467
|
+
*/
|
|
1468
|
+
function abortTunnel(engine, id) {
|
|
1469
|
+
try {
|
|
1470
|
+
stopTunnel(engine, id);
|
|
1471
|
+
} catch {}
|
|
1472
|
+
}
|
|
1416
1473
|
/** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
|
|
1417
1474
|
async function startTunnel(engine, alias, options) {
|
|
1418
1475
|
if (!Number.isInteger(options.remotePort) || options.remotePort < 1 || options.remotePort > 65535) throw new Error("remotePort must be an integer in 1..65535");
|
|
@@ -1435,30 +1492,44 @@ async function startTunnel(engine, alias, options) {
|
|
|
1435
1492
|
const record = engine.pool.get(alias) ?? await acquire(engine, alias);
|
|
1436
1493
|
const client = record.client;
|
|
1437
1494
|
const sockets = /* @__PURE__ */ new Set();
|
|
1495
|
+
const onLinkClosed = () => {
|
|
1496
|
+
abortTunnel(engine, id);
|
|
1497
|
+
};
|
|
1498
|
+
const detachLink = () => {
|
|
1499
|
+
client.off("close", onLinkClosed);
|
|
1500
|
+
};
|
|
1438
1501
|
const server = createServer((socket) => {
|
|
1439
1502
|
sockets.add(socket);
|
|
1440
1503
|
socket.on("close", () => {
|
|
1441
1504
|
sockets.delete(socket);
|
|
1442
1505
|
});
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1506
|
+
if (record.broken) {
|
|
1507
|
+
destroyQuietly(socket);
|
|
1508
|
+
abortTunnel(engine, id);
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
try {
|
|
1512
|
+
client.forwardOut("127.0.0.1", 0, remoteHost, options.remotePort, (error, stream) => {
|
|
1513
|
+
if (error !== void 0) {
|
|
1514
|
+
destroyQuietly(socket);
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
const destroy = () => {
|
|
1518
|
+
destroyQuietly(socket);
|
|
1519
|
+
try {
|
|
1520
|
+
stream.close();
|
|
1521
|
+
} catch {}
|
|
1522
|
+
};
|
|
1523
|
+
stream.on("error", destroy);
|
|
1524
|
+
socket.on("error", destroy);
|
|
1525
|
+
stream.on("close", destroy);
|
|
1526
|
+
socket.on("close", destroy);
|
|
1527
|
+
stream.pipe(socket).pipe(stream);
|
|
1528
|
+
});
|
|
1529
|
+
} catch {
|
|
1530
|
+
destroyQuietly(socket);
|
|
1531
|
+
abortTunnel(engine, id);
|
|
1532
|
+
}
|
|
1462
1533
|
});
|
|
1463
1534
|
try {
|
|
1464
1535
|
await new Promise((resolve, reject) => {
|
|
@@ -1481,8 +1552,15 @@ async function startTunnel(engine, alias, options) {
|
|
|
1481
1552
|
server,
|
|
1482
1553
|
alias,
|
|
1483
1554
|
record,
|
|
1484
|
-
sockets
|
|
1555
|
+
sockets,
|
|
1556
|
+
detachLink
|
|
1485
1557
|
});
|
|
1558
|
+
client.once("close", onLinkClosed);
|
|
1559
|
+
if (record.broken) {
|
|
1560
|
+
info.state = "failed";
|
|
1561
|
+
info.error = "ssh link closed while the tunnel was starting";
|
|
1562
|
+
abortTunnel(engine, id);
|
|
1563
|
+
}
|
|
1486
1564
|
return info;
|
|
1487
1565
|
}
|
|
1488
1566
|
/** All active tunnels. */
|
|
@@ -1495,11 +1573,12 @@ function stopTunnel(engine, id) {
|
|
|
1495
1573
|
if (tunnel === void 0) return false;
|
|
1496
1574
|
engine.tunnels.delete(id);
|
|
1497
1575
|
try {
|
|
1498
|
-
tunnel.
|
|
1576
|
+
tunnel.detachLink?.();
|
|
1499
1577
|
} catch {}
|
|
1500
|
-
|
|
1501
|
-
|
|
1578
|
+
try {
|
|
1579
|
+
tunnel.server.close();
|
|
1502
1580
|
} catch {}
|
|
1581
|
+
for (const socket of tunnel.sockets) destroyQuietly(socket);
|
|
1503
1582
|
tunnel.sockets.clear();
|
|
1504
1583
|
if (![...engine.tunnels.values()].some((candidate) => candidate.record === tunnel.record)) if (engine.pool.get(tunnel.alias) === tunnel.record) disposeRecord(engine, tunnel.alias, tunnel.record);
|
|
1505
1584
|
else endRecordChain(tunnel.record);
|
|
@@ -1535,7 +1614,7 @@ async function cluster(engine, options) {
|
|
|
1535
1614
|
while (queue.length > 0) {
|
|
1536
1615
|
const entry = queue.shift();
|
|
1537
1616
|
try {
|
|
1538
|
-
const result = await execCommand(engine, entry.alias, options.command, options.timeoutMs);
|
|
1617
|
+
const result = await execCommand(engine, entry.alias, options.command, options.timeoutMs, options.signal);
|
|
1539
1618
|
results.push({
|
|
1540
1619
|
alias: entry.alias,
|
|
1541
1620
|
ok: result.success,
|
|
@@ -1595,8 +1674,8 @@ var SshEngine = class {
|
|
|
1595
1674
|
return entry === void 0 ? void 0 : this.store.summarize(entry);
|
|
1596
1675
|
}
|
|
1597
1676
|
/** Run one command on `alias` (reusing the pooled connection). */
|
|
1598
|
-
async exec(alias, command, timeoutMs) {
|
|
1599
|
-
return execCommand(this, alias, command, timeoutMs);
|
|
1677
|
+
async exec(alias, command, timeoutMs, signal) {
|
|
1678
|
+
return execCommand(this, alias, command, timeoutMs, signal);
|
|
1600
1679
|
}
|
|
1601
1680
|
/** Run one command against many hosts concurrently. */
|
|
1602
1681
|
async cluster(options) {
|
|
@@ -2594,7 +2673,7 @@ function renderTunnel(tunnel) {
|
|
|
2594
2673
|
function sshListTool(engine) {
|
|
2595
2674
|
return defineTool({
|
|
2596
2675
|
name: "ssh_list",
|
|
2597
|
-
description: "List configured SSH hosts (alias, host, user, auth, environment, tags, description). Use ssh_exec etc. with the alias.
|
|
2676
|
+
description: "List configured SSH hosts (alias, host, user, auth, environment, tags, description). Use ssh_exec etc. with the alias.",
|
|
2598
2677
|
parameters: { query: {
|
|
2599
2678
|
type: "string",
|
|
2600
2679
|
description: "Optional fuzzy match against alias, description, host, and tags."
|
|
@@ -2670,6 +2749,7 @@ function sshListTool(engine) {
|
|
|
2670
2749
|
},
|
|
2671
2750
|
render: (_args, value) => text(renderHosts(value.hosts ?? []))
|
|
2672
2751
|
},
|
|
2752
|
+
isConcurrencySafe: () => true,
|
|
2673
2753
|
async execute(args) {
|
|
2674
2754
|
return { hosts: engine.list(args.query).map(toAgentHostRow) };
|
|
2675
2755
|
}
|
|
@@ -2679,7 +2759,7 @@ function sshListTool(engine) {
|
|
|
2679
2759
|
function sshExecTool(engine) {
|
|
2680
2760
|
return defineTool({
|
|
2681
2761
|
name: "ssh_exec",
|
|
2682
|
-
description: "Execute a shell command on a REMOTE SSH host by alias; the command runs on the remote host, never on this machine. For commands on this machine, use the local bash tool. Prefer combining independent read-only queries into one command.
|
|
2762
|
+
description: "Execute a shell command on a REMOTE SSH host by alias; the command runs on the remote host, never on this machine. For commands on this machine, use the local bash tool. Prefer combining independent read-only queries into one command.",
|
|
2683
2763
|
parameters: {
|
|
2684
2764
|
alias: {
|
|
2685
2765
|
type: "string",
|
|
@@ -2730,9 +2810,9 @@ function sshExecTool(engine) {
|
|
|
2730
2810
|
},
|
|
2731
2811
|
render: (_args, value) => text(renderExec(value))
|
|
2732
2812
|
},
|
|
2733
|
-
async execute(args) {
|
|
2813
|
+
async execute(args, exec) {
|
|
2734
2814
|
try {
|
|
2735
|
-
return await engine.exec(args.alias, args.command, args.timeoutMs);
|
|
2815
|
+
return await engine.exec(args.alias, args.command, args.timeoutMs, exec.signal);
|
|
2736
2816
|
} catch (error) {
|
|
2737
2817
|
return {
|
|
2738
2818
|
success: false,
|
|
@@ -2751,7 +2831,7 @@ function sshExecTool(engine) {
|
|
|
2751
2831
|
function sshUploadTool(engine) {
|
|
2752
2832
|
return defineTool({
|
|
2753
2833
|
name: "ssh_upload",
|
|
2754
|
-
description: "Transfer a file FROM this machine (the dsh host) TO a remote SSH host. Use this only when the file must be copied to the remote host. Files that stay on this machine are handled with the local file tools (read / write / edit), not ssh_upload.
|
|
2834
|
+
description: "Transfer a file FROM this machine (the dsh host) TO a remote SSH host. Use this only when the file must be copied to the remote host. Files that stay on this machine are handled with the local file tools (read / write / edit), not ssh_upload.",
|
|
2755
2835
|
parameters: {
|
|
2756
2836
|
alias: {
|
|
2757
2837
|
type: "string",
|
|
@@ -2806,7 +2886,7 @@ function sshUploadTool(engine) {
|
|
|
2806
2886
|
function sshDownloadTool(engine) {
|
|
2807
2887
|
return defineTool({
|
|
2808
2888
|
name: "ssh_download",
|
|
2809
|
-
description: "Copy a remote FILE from a configured SSH host to this machine (the dsh host). Use this only when the source is on the remote host; files already on this machine are read with the local file tools (read / write / edit), not ssh_download. Directory download is not supported — download files individually.
|
|
2889
|
+
description: "Copy a remote FILE from a configured SSH host to this machine (the dsh host). Use this only when the source is on the remote host; files already on this machine are read with the local file tools (read / write / edit), not ssh_download. Directory download is not supported — download files individually.",
|
|
2810
2890
|
parameters: {
|
|
2811
2891
|
alias: {
|
|
2812
2892
|
type: "string",
|
|
@@ -2858,7 +2938,7 @@ function sshDownloadTool(engine) {
|
|
|
2858
2938
|
function sshTunnelTool(engine) {
|
|
2859
2939
|
return defineTool({
|
|
2860
2940
|
name: "ssh_tunnel",
|
|
2861
|
-
description: "Manage local port-forward tunnels to a configured SSH host. Start a tunnel to reach a remote internal service (database, web UI, API) through 127.0.0.1 on this machine.
|
|
2941
|
+
description: "Manage local port-forward tunnels to a configured SSH host. Start a tunnel to reach a remote internal service (database, web UI, API) through 127.0.0.1 on this machine.",
|
|
2862
2942
|
parameters: {
|
|
2863
2943
|
action: {
|
|
2864
2944
|
type: "string",
|
|
@@ -3053,7 +3133,7 @@ function sshTunnelTool(engine) {
|
|
|
3053
3133
|
function sshClusterTool(engine) {
|
|
3054
3134
|
return defineTool({
|
|
3055
3135
|
name: "ssh_cluster",
|
|
3056
|
-
description: "Run one command concurrently across selected SSH hosts; at least one aliases, environment, or tags filter is required.
|
|
3136
|
+
description: "Run one command concurrently across selected SSH hosts; at least one aliases, environment, or tags filter is required.",
|
|
3057
3137
|
parameters: {
|
|
3058
3138
|
command: {
|
|
3059
3139
|
type: "string",
|
|
@@ -3114,9 +3194,12 @@ function sshClusterTool(engine) {
|
|
|
3114
3194
|
},
|
|
3115
3195
|
render: (_args, value) => text(renderCluster(value.results ?? []))
|
|
3116
3196
|
},
|
|
3117
|
-
async execute(args) {
|
|
3197
|
+
async execute(args, exec) {
|
|
3118
3198
|
if (!(Array.isArray(args.aliases) && args.aliases.some((alias) => typeof alias === "string" && alias.trim() !== "") || typeof args.environment === "string" && args.environment.trim() !== "" || Array.isArray(args.tags) && args.tags.some((tag) => typeof tag === "string" && tag.trim() !== ""))) throw new Error("ssh_cluster requires aliases, environment, or tags to limit the target set");
|
|
3119
|
-
return { results: await engine.cluster(
|
|
3199
|
+
return { results: await engine.cluster({
|
|
3200
|
+
...args,
|
|
3201
|
+
signal: exec.signal
|
|
3202
|
+
}) };
|
|
3120
3203
|
}
|
|
3121
3204
|
});
|
|
3122
3205
|
}
|
|
@@ -3235,6 +3318,66 @@ function mountOnce(packageName, fn) {
|
|
|
3235
3318
|
return mount;
|
|
3236
3319
|
}
|
|
3237
3320
|
//#endregion
|
|
3321
|
+
//#region src/tool-surface.ts
|
|
3322
|
+
/**
|
|
3323
|
+
* Family-shared tool-surface conventions for the packages that register
|
|
3324
|
+
* model-facing tools.
|
|
3325
|
+
*
|
|
3326
|
+
* One home for the prompt-section placement and the visibility gate this
|
|
3327
|
+
* family previously restated per package.
|
|
3328
|
+
*
|
|
3329
|
+
* Modelled on the official tool packages:
|
|
3330
|
+
* - `@deepseek-ai/dsh-tool-subagent` renders its section through
|
|
3331
|
+
* `tools.get(name, context.scope) === undefined ? '' : text`, so guidance
|
|
3332
|
+
* disappears together with a restricted tool.
|
|
3333
|
+
* - 21 of the 22 official tool packages register their tools GLOBALLY and gate
|
|
3334
|
+
* them with a setting, not per agent. Only the Agent Teams tool package —
|
|
3335
|
+
* whose tools belong to one membership — installs into each `agent.ctx`.
|
|
3336
|
+
* This family follows the majority: registration stays global so the
|
|
3337
|
+
* registry's per-scope `restrict()` mask (which filters inherited global
|
|
3338
|
+
* tools and REJECTS scope-local names) can still withhold these tools.
|
|
3339
|
+
*
|
|
3340
|
+
* Structural types only: this copy lands in packages that do not all depend on
|
|
3341
|
+
* the same SDK service packages.
|
|
3342
|
+
*/
|
|
3343
|
+
/**
|
|
3344
|
+
* Prompt-section order for each package's tool guidance.
|
|
3345
|
+
*
|
|
3346
|
+
* These sit in the family band between the deployment persona and the SDK's
|
|
3347
|
+
* `PLAN_POLICY` (500). Values are owned here rather than written per package so
|
|
3348
|
+
* the host plugins whose guidance shares one prompt cannot drift into an
|
|
3349
|
+
* accidental tie.
|
|
3350
|
+
*/
|
|
3351
|
+
const PLUGIN_TOOL_SECTION_ORDERS = {
|
|
3352
|
+
ssh: 150,
|
|
3353
|
+
"task-board": 200
|
|
3354
|
+
};
|
|
3355
|
+
/**
|
|
3356
|
+
* Build a section provider that renders \`text\` only while at least one of
|
|
3357
|
+
* \`toolNames\` is visible to the rendering scope.
|
|
3358
|
+
*
|
|
3359
|
+
* The native convention: a tool that a restriction, a preset, or a missing
|
|
3360
|
+
* registry removed must not leave guidance behind telling the model to call it.
|
|
3361
|
+
* An unreadable registry renders the text unchanged — guidance that outlives a
|
|
3362
|
+
* momentary read failure is recoverable; guidance for a tool that is gone is
|
|
3363
|
+
* not.
|
|
3364
|
+
* @param tools - the scoped tool registry, when the deployment serves one.
|
|
3365
|
+
* @param toolNames - registered names the guidance describes.
|
|
3366
|
+
* @param text - the guidance rendered while they are reachable.
|
|
3367
|
+
* @returns the section text provider.
|
|
3368
|
+
*/
|
|
3369
|
+
function visibleToolText(tools, toolNames, text) {
|
|
3370
|
+
if (tools === void 0) return () => text;
|
|
3371
|
+
return (context) => {
|
|
3372
|
+
for (const name of toolNames) try {
|
|
3373
|
+
if (tools.get(name, context.scope) !== void 0) return text;
|
|
3374
|
+
} catch {
|
|
3375
|
+
return text;
|
|
3376
|
+
}
|
|
3377
|
+
return "";
|
|
3378
|
+
};
|
|
3379
|
+
}
|
|
3380
|
+
//#endregion
|
|
3238
3381
|
//#region src/index.ts
|
|
3239
3382
|
/** Stable cordis plugin name. */
|
|
3240
3383
|
const name = "ssh";
|
|
@@ -3275,8 +3418,17 @@ function profileConfig(ctx) {
|
|
|
3275
3418
|
if (typeof config !== "object" || config === null || Array.isArray(config)) return void 0;
|
|
3276
3419
|
return config;
|
|
3277
3420
|
}
|
|
3278
|
-
/** Order of the announcement section within the tool-guidance band. */
|
|
3279
|
-
const SECTION_ORDER =
|
|
3421
|
+
/** Order of the announcement section within the shared tool-guidance band. */
|
|
3422
|
+
const SECTION_ORDER = PLUGIN_TOOL_SECTION_ORDERS.ssh;
|
|
3423
|
+
/** The six agent-tool names this plugin's announcement describes. */
|
|
3424
|
+
const SSH_TOOL_NAMES = [
|
|
3425
|
+
"ssh_list",
|
|
3426
|
+
"ssh_exec",
|
|
3427
|
+
"ssh_upload",
|
|
3428
|
+
"ssh_download",
|
|
3429
|
+
"ssh_tunnel",
|
|
3430
|
+
"ssh_cluster"
|
|
3431
|
+
];
|
|
3280
3432
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
3281
3433
|
const SSH_GUIDANCE = "本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维):侧边栏「SSH」入口;在 dsh-web 插件全家桶仓库(packages/dsh-ssh)统一维护。能力:主机配置存 $DSH_HOME/dsh-ssh.json(默认 ~/.dsh)(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 按 aliases/environment/tags 至少一种非空 selector 筛选后集群并发执行;支持密钥/密码/ssh-agent 认证、passphrase 密钥、ProxyJump 跳板机(别名或 [user@]host[:port] 地址)与 OpenSSH 语义的 ProxyCommand(跳过堡垒机客户端场景,只能由用户在 GUI 配置,agent 不可写);Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;断线重连可能重放非幂等命令;传输/执行消耗真实远程资源,先确认再操作。路径区分:本机(dsh host)上的文件与命令一律用本地工具(read / write / edit / bash),ssh_* 工具只针对远程主机上的路径。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。";
|
|
3282
3434
|
/**
|
|
@@ -3332,7 +3484,7 @@ function applyImpl(ctx, config) {
|
|
|
3332
3484
|
if (value.announceToAgent) disposeSection = ctx.systemPrompt.section({
|
|
3333
3485
|
name: "plugin:dsh-ssh",
|
|
3334
3486
|
order: SECTION_ORDER,
|
|
3335
|
-
text: SSH_GUIDANCE
|
|
3487
|
+
text: visibleToolText(ctx.tools, SSH_TOOL_NAMES, SSH_GUIDANCE)
|
|
3336
3488
|
});
|
|
3337
3489
|
disposeRoutes = ctx.effect(() => {
|
|
3338
3490
|
const disposers = routes.map((route) => ctx.webServer.register(route));
|
|
@@ -18,22 +18,33 @@ interface PageLocation {
|
|
|
18
18
|
* The WebSocket URL the terminal route is reached at, or undefined when this
|
|
19
19
|
* page cannot carry one.
|
|
20
20
|
*
|
|
21
|
-
* A page
|
|
22
|
-
* Desktop shell serves its Web GUI from `dsh-app://app/`) has no WebSocket
|
|
23
|
-
* transport: its scheme handler forwards HTTP requests to the local host but
|
|
24
|
-
* cannot upgrade a socket, so `ws://app/...` never connects and the terminal
|
|
25
|
-
* reports "connection error" (issue #1744). Every other page is a web page and
|
|
26
|
-
* resolves the socket against its own origin, exactly as before.
|
|
21
|
+
* A web page resolves the socket against its own origin, exactly as before.
|
|
27
22
|
*
|
|
28
|
-
* A
|
|
29
|
-
*
|
|
30
|
-
*
|
|
23
|
+
* A page delivered by an application on this machine (the official DSH Desktop
|
|
24
|
+
* shell serves its Web GUI from `dsh-app://app/`) cannot carry a socket on its
|
|
25
|
+
* own scheme: the protocol handler forwards HTTP to the local Host but has no
|
|
26
|
+
* upgrade to forward. That is exactly the case the official transport hook
|
|
27
|
+
* covers - the shell publishes `__DSH_TRANSPORT__.streamBaseUrl`, the loopback
|
|
28
|
+
* authority of the Host it owns and already forwards every other request to
|
|
29
|
+
* (issue #1744). The socket is therefore dialed there instead, with the `ws`/
|
|
30
|
+
* `wss` scheme derived from that base, which is what the shell's
|
|
31
|
+
* `onBeforeSendHeaders` hook expects to see before it attaches its
|
|
32
|
+
* authority-bound credential.
|
|
33
|
+
*
|
|
34
|
+
* The base is used only on an application-delivered page. A web page that
|
|
35
|
+
* somehow carries the hook (the remote channel grants `ownsHost` to a paired
|
|
36
|
+
* LAN page, and that page is fenced behind a pairing channel) keeps resolving
|
|
37
|
+
* against its own origin, so this cannot reroute gated traffic onto a host the
|
|
38
|
+
* page is not entitled to reach. A blank authority is treated the same as an
|
|
39
|
+
* application scheme with no base: there is nothing to dial, and the caller
|
|
40
|
+
* gets the actionable reason instead of a socket that can only fail.
|
|
31
41
|
*
|
|
32
42
|
* @param location - the page location to read.
|
|
33
43
|
* @param search - the query string carrying `alias` or `session`.
|
|
44
|
+
* @param streamBaseUrl - `__DSH_TRANSPORT__.streamBaseUrl`, when the shell published one.
|
|
34
45
|
* @returns the absolute `ws:`/`wss:` URL, or undefined when the page cannot carry one.
|
|
35
46
|
*/
|
|
36
|
-
export declare function terminalSocketUrl(location: PageLocation, search: string): string | undefined;
|
|
47
|
+
export declare function terminalSocketUrl(location: PageLocation, search: string, streamBaseUrl?: string | undefined): string | undefined;
|
|
37
48
|
/** One open terminal connection (WebSocket JSON frames). */
|
|
38
49
|
export interface TerminalConnection {
|
|
39
50
|
/** Fired on the ready frame (shell is up); carries the host session id and alias. */
|
|
@@ -89,7 +89,7 @@ export declare const zh: {
|
|
|
89
89
|
readonly 'terminal.ready': "终端已连接({alias})";
|
|
90
90
|
readonly 'terminal.exited': "终端已退出({alias})";
|
|
91
91
|
readonly 'terminal.error': "终端错误:{error}";
|
|
92
|
-
readonly 'terminal.noWebSocket': "当前页面不支持 WebSocket
|
|
92
|
+
readonly 'terminal.noWebSocket': "当前页面不支持 WebSocket(应用外壳没有可连接的本地主机地址),请改用浏览器打开的 Web 界面使用终端。";
|
|
93
93
|
readonly 'terminal.auth.title': "二次身份验证 (2FA / 交互式认证)";
|
|
94
94
|
readonly 'terminal.auth.submit': "提交验证码";
|
|
95
95
|
readonly 'terminal.auth.cancel': "取消";
|
|
@@ -11,6 +11,8 @@ interface ClusterOptions {
|
|
|
11
11
|
tags?: string[];
|
|
12
12
|
timeoutMs?: number;
|
|
13
13
|
maxWorkers?: number;
|
|
14
|
+
/** Caller cancellation, forwarded to every host's command. */
|
|
15
|
+
signal?: AbortSignal;
|
|
14
16
|
}
|
|
15
17
|
/** Run one command against many hosts concurrently. */
|
|
16
18
|
export declare function cluster(engine: PoolEngine, options: ClusterOptions): Promise<ClusterResult[]>;
|
|
@@ -107,6 +107,6 @@ export declare function sweepPool(engine: PoolEngine): void;
|
|
|
107
107
|
* Run `fn` with a live client for `alias`, reconnecting (up to the
|
|
108
108
|
* attempt budget) when the connection broke mid-flight.
|
|
109
109
|
*/
|
|
110
|
-
export declare function withClient<T>(engine: PoolEngine, alias: string, fn: (client: Client) => Promise<T>, attempts?: number): Promise<T>;
|
|
110
|
+
export declare function withClient<T>(engine: PoolEngine, alias: string, fn: (client: Client) => Promise<T>, attempts?: number, signal?: AbortSignal): Promise<T>;
|
|
111
111
|
/** Run one command on `alias` (reusing the pooled connection). */
|
|
112
|
-
export declare function execCommand(engine: PoolEngine, alias: string, command: string, timeoutMs?: number): Promise<ExecResult>;
|
|
112
|
+
export declare function execCommand(engine: PoolEngine, alias: string, command: string, timeoutMs?: number, signal?: AbortSignal): Promise<ExecResult>;
|
|
@@ -14,6 +14,8 @@ export interface TunnelRecord {
|
|
|
14
14
|
/** The pooled connection this tunnel pins; siblings on one alias may share it. */
|
|
15
15
|
record: PoolRecord;
|
|
16
16
|
sockets: Set<Socket>;
|
|
17
|
+
/** Detach the link-death hook startTunnel installs on the pooled client. */
|
|
18
|
+
detachLink?: () => void;
|
|
17
19
|
}
|
|
18
20
|
/** The engine slice the tunnel module needs (adds the tunnel registry). */
|
|
19
21
|
export interface TunnelEngine extends PoolEngine {
|
package/lib/types/engine.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ export declare class SshEngine {
|
|
|
36
36
|
/** One host summary by alias. */
|
|
37
37
|
find(alias: string): SshHostSummary | undefined;
|
|
38
38
|
/** Run one command on `alias` (reusing the pooled connection). */
|
|
39
|
-
exec(alias: string, command: string, timeoutMs?: number): Promise<ExecResult>;
|
|
39
|
+
exec(alias: string, command: string, timeoutMs?: number, signal?: AbortSignal): Promise<ExecResult>;
|
|
40
40
|
/** Run one command against many hosts concurrently. */
|
|
41
41
|
cluster(options: {
|
|
42
42
|
command: string;
|
|
@@ -45,6 +45,7 @@ export declare class SshEngine {
|
|
|
45
45
|
tags?: string[];
|
|
46
46
|
timeoutMs?: number;
|
|
47
47
|
maxWorkers?: number;
|
|
48
|
+
signal?: AbortSignal;
|
|
48
49
|
}): Promise<ClusterResult[]>;
|
|
49
50
|
/** Open a PTY shell session for the web terminal (standalone connection). */
|
|
50
51
|
openShell(alias: string, size: {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Family-shared tool-surface conventions for the packages that register
|
|
3
|
+
* model-facing tools.
|
|
4
|
+
*
|
|
5
|
+
* One home for the prompt-section placement and the visibility gate this
|
|
6
|
+
* family previously restated per package.
|
|
7
|
+
*
|
|
8
|
+
* Modelled on the official tool packages:
|
|
9
|
+
* - `@deepseek-ai/dsh-tool-subagent` renders its section through
|
|
10
|
+
* `tools.get(name, context.scope) === undefined ? '' : text`, so guidance
|
|
11
|
+
* disappears together with a restricted tool.
|
|
12
|
+
* - 21 of the 22 official tool packages register their tools GLOBALLY and gate
|
|
13
|
+
* them with a setting, not per agent. Only the Agent Teams tool package —
|
|
14
|
+
* whose tools belong to one membership — installs into each `agent.ctx`.
|
|
15
|
+
* This family follows the majority: registration stays global so the
|
|
16
|
+
* registry's per-scope `restrict()` mask (which filters inherited global
|
|
17
|
+
* tools and REJECTS scope-local names) can still withhold these tools.
|
|
18
|
+
*
|
|
19
|
+
* Structural types only: this copy lands in packages that do not all depend on
|
|
20
|
+
* the same SDK service packages.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Prompt-section order for each package's tool guidance.
|
|
24
|
+
*
|
|
25
|
+
* These sit in the family band between the deployment persona and the SDK's
|
|
26
|
+
* `PLAN_POLICY` (500). Values are owned here rather than written per package so
|
|
27
|
+
* the host plugins whose guidance shares one prompt cannot drift into an
|
|
28
|
+
* accidental tie.
|
|
29
|
+
*/
|
|
30
|
+
export declare const PLUGIN_TOOL_SECTION_ORDERS: {
|
|
31
|
+
readonly ssh: 150;
|
|
32
|
+
readonly 'task-board': 200;
|
|
33
|
+
};
|
|
34
|
+
/** One plugin id keyed by {@link PLUGIN_TOOL_SECTION_ORDERS}. */
|
|
35
|
+
export type PluginToolSectionId = keyof typeof PLUGIN_TOOL_SECTION_ORDERS;
|
|
36
|
+
/**
|
|
37
|
+
* Order of an external provider extension's own guidance section, right after
|
|
38
|
+
* the band of the plugin it extends.
|
|
39
|
+
*
|
|
40
|
+
* An extension names itself, not the plugin it plugs into, so its order stays
|
|
41
|
+
* out of {@link PLUGIN_TOOL_SECTION_ORDERS}: every package's copy of this module
|
|
42
|
+
* then carries no provider id. The slot still has one owner and cannot collide
|
|
43
|
+
* with a host plugin's band.
|
|
44
|
+
*/
|
|
45
|
+
export declare const EXTENSION_TOOL_SECTION_ORDER = 210;
|
|
46
|
+
/**
|
|
47
|
+
* The scoped tool lookup a section provider needs: the registry read for one
|
|
48
|
+
* assembly's scope. Matches `ctx.tools.get(name, scope)`.
|
|
49
|
+
*/
|
|
50
|
+
export interface ScopedToolLookup {
|
|
51
|
+
get(name: string, scope?: unknown): unknown;
|
|
52
|
+
}
|
|
53
|
+
/** One assembly context carrying the scope the section is rendered for. */
|
|
54
|
+
export interface SectionScopeContext {
|
|
55
|
+
scope?: unknown;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Build a section provider that renders \`text\` only while at least one of
|
|
59
|
+
* \`toolNames\` is visible to the rendering scope.
|
|
60
|
+
*
|
|
61
|
+
* The native convention: a tool that a restriction, a preset, or a missing
|
|
62
|
+
* registry removed must not leave guidance behind telling the model to call it.
|
|
63
|
+
* An unreadable registry renders the text unchanged — guidance that outlives a
|
|
64
|
+
* momentary read failure is recoverable; guidance for a tool that is gone is
|
|
65
|
+
* not.
|
|
66
|
+
* @param tools - the scoped tool registry, when the deployment serves one.
|
|
67
|
+
* @param toolNames - registered names the guidance describes.
|
|
68
|
+
* @param text - the guidance rendered while they are reachable.
|
|
69
|
+
* @returns the section text provider.
|
|
70
|
+
*/
|
|
71
|
+
export declare function visibleToolText(tools: ScopedToolLookup | undefined, toolNames: readonly string[], text: string): (context: SectionScopeContext) => string;
|