diff --git a/CHANGELOG.md b/CHANGELOG.md index c533c78b..28e92e04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixed +- 修复 WebShell 批量探活因请求构建异常一直停留在“探活中”的问题;使用连接自身 ID 读取已保存凭据,并在探活失败后恢复按钮、继续处理其他连接。 + - 修复审计 Agent 向不支持 thinking 字段的兼容网关发起请求失败的问题;支持独立兼容 profile,并按模型和地址识别默认行为。 - 修复已知支持 `none` 的官方 OpenAI 模型在关闭推理后仍使用服务端默认推理的问题(#366):按提供商、端点和模型发送并保留显式关闭参数,覆盖 Planner 和强制工具选择;保留 DeepSeek 与第三方兼容网关行为,并明确提示需要 Responses 的工具调用限制。 diff --git a/web/static/js/webshell-probe.test.cjs b/web/static/js/webshell-probe.test.cjs new file mode 100644 index 00000000..3d2d71f9 --- /dev/null +++ b/web/static/js/webshell-probe.test.cjs @@ -0,0 +1,60 @@ +const fs = require('node:fs'); +const vm = require('node:vm'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const source = fs.readFileSync('web/static/js/webshell.js', 'utf8'); +function harness(reply) { + const requests = []; + const button = {}; + const ctx = { + webshellConnections: Array.from({length: 6}, (_, i) => ({id: `conn-${i}`, url: 'http://example.invalid', password: '********'})), + webshellBatchProbeRunning: false, webshellProbeStatusById: {}, + wsT: key => key, renderWebshellList() {}, + document: {getElementById: id => id === 'webshell-batch-probe-btn' ? button : {value: 'unrelated-edit-id'}}, + webshellConnEncoding: () => 'utf-8', webshellConnOS: () => 'linux', + apiFetch: (url, options) => { + const body = JSON.parse(options.body); + requests.push(body); + return reply(body); + } + }; + vm.createContext(ctx); + for (const name of ['buildWebshellProbeToken', 'buildWebshellProbeCommand', 'isWebshellProbeOutputMatched', 'probeWebshellConnection', 'updateWebshellBatchProbeButton', 'runBatchProbeWebshellConnections']) { + const start = source.indexOf(`function ${name}(`); + const end = source.indexOf('\nfunction ', start + 1); + assert.ok(start >= 0 && end > start); + vm.runInContext(source.slice(start, end), ctx); + } + return {ctx, requests, button}; +} +function success(body) { + return Promise.resolve({json: async () => ({ok: true, output: body.command.replace(/^echo /, '')})}); +} +test('saved masked credentials use each connection ID and complete a batch, then allow retry', async () => { + const {ctx, requests, button} = harness(success); + await ctx.runBatchProbeWebshellConnections(); + assert.deepEqual(requests.map(r => r.connection_id).sort(), Array.from({length: 6}, (_, i) => `conn-${i}`)); + assert.ok(Object.values(ctx.webshellProbeStatusById).every(s => s.state === 'ok')); + assert.equal(ctx.webshellBatchProbeRunning, false); + assert.equal(button.disabled, false); + await ctx.runBatchProbeWebshellConnections(); + assert.equal(requests.length, 12); +}); +for (const failure of ['sync', 'network', 'json', 'server', 'mismatch']) { + test(`${failure} failure settles offline, completes other connections and restores button`, async () => { + const {ctx, button, requests} = harness(body => { + if (body.connection_id !== 'conn-0') return success(body); + if (failure === 'sync') throw new Error('sync failure'); + if (failure === 'network') return Promise.reject(new Error('network failure')); + if (failure === 'json') return Promise.resolve({json: () => Promise.reject(new Error('invalid JSON'))}); + return Promise.resolve({json: async () => ({ok: failure === 'mismatch', output: '', error: 'probe failed'})}); + }); + await ctx.runBatchProbeWebshellConnections(); + assert.equal(requests.length, 6); + assert.equal(ctx.webshellProbeStatusById['conn-0'].state, 'fail'); + assert.ok(ctx.webshellProbeStatusById['conn-0'].message); + assert.equal(Object.values(ctx.webshellProbeStatusById).filter(s => s.state === 'ok').length, 5); + assert.equal(ctx.webshellBatchProbeRunning, false); + assert.equal(button.disabled, false); + }); +} diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js index b71e3e7c..8a8a5302 100644 --- a/web/static/js/webshell.js +++ b/web/static/js/webshell.js @@ -1232,21 +1232,23 @@ function probeWebshellConnection(conn) { return Promise.resolve({ ok: false, message: wsT('webshell.testFailed') || '连通性测试失败' }); } var probeToken = buildWebshellProbeToken(); - return apiFetch('/api/webshell/exec', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - url: conn.url, - password: conn.password || '', - type: conn.type || 'php', - method: ((conn.method || 'post').toLowerCase() === 'get') ? 'get' : 'post', - cmd_param: conn.cmdParam || '', - encoding: webshellConnEncoding(conn), - os: webshellConnOS(conn), - connection_id: conn.id || '', - command: buildWebshellProbeCommand(probeToken), - connection_id: password === '********' ? (document.getElementById('webshell-edit-id')?.value || '') : '' - }) + // Include request construction and synchronous transport errors in the failure path. + return Promise.resolve().then(function () { + return apiFetch('/api/webshell/exec', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: conn.url, + password: conn.password || '', + type: conn.type || 'php', + method: ((conn.method || 'post').toLowerCase() === 'get') ? 'get' : 'post', + cmd_param: conn.cmdParam || '', + encoding: webshellConnEncoding(conn), + os: webshellConnOS(conn), + connection_id: conn.id || '', + command: buildWebshellProbeCommand(probeToken) + }) + }); }) .then(function (r) { return r.json(); }) .then(function (data) { @@ -1322,7 +1324,7 @@ function runBatchProbeWebshellConnections() { var workers = []; for (var i = 0; i < concurrency; i++) workers.push(runOne()); - Promise.all(workers).finally(function () { + return Promise.all(workers).finally(function () { webshellBatchProbeRunning = false; updateWebshellBatchProbeButton(done, total, okCount); });