@cjhyy/code-shell-server 0.9.7 → 0.9.9
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.md +85 -23
- package/dist/bin/code-shell-project-runtime.d.ts +2 -0
- package/dist/bin/code-shell-project-runtime.js +8 -0
- package/dist/desktop-web/http-api.d.ts +43 -0
- package/dist/desktop-web/http-api.js +376 -0
- package/dist/hub/approval-lease.d.ts +30 -0
- package/dist/hub/approval-lease.js +89 -0
- package/dist/hub/auth-http.d.ts +23 -0
- package/dist/hub/auth-http.js +259 -0
- package/dist/hub/auth-store.d.ts +52 -0
- package/dist/hub/auth-store.js +316 -0
- package/dist/hub/configuration.d.ts +64 -0
- package/dist/hub/configuration.js +555 -0
- package/dist/hub/files.d.ts +9 -0
- package/dist/hub/files.js +278 -0
- package/dist/hub/mcp-configuration.d.ts +51 -0
- package/dist/hub/mcp-configuration.js +558 -0
- package/dist/hub/mcp-probe.d.ts +24 -0
- package/dist/hub/mcp-probe.js +392 -0
- package/dist/hub/model-probe.d.ts +20 -0
- package/dist/hub/model-probe.js +193 -0
- package/dist/hub/outbound-transport.d.ts +36 -0
- package/dist/hub/outbound-transport.js +122 -0
- package/dist/hub/run-replay.d.ts +42 -0
- package/dist/hub/run-replay.js +89 -0
- package/dist/hub/session-management.d.ts +36 -0
- package/dist/hub/session-management.js +405 -0
- package/dist/hub/skills-management.d.ts +43 -0
- package/dist/hub/skills-management.js +487 -0
- package/dist/hub/uploads.d.ts +22 -0
- package/dist/hub/uploads.js +241 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.desktop-web.d.ts +2 -0
- package/dist/index.desktop-web.js +2 -0
- package/dist/index.js +3 -0
- package/dist/index.links.d.ts +6 -0
- package/dist/index.links.js +6 -0
- package/dist/index.panels.d.ts +9 -0
- package/dist/index.panels.js +8 -0
- package/dist/index.serve.d.ts +1 -0
- package/dist/index.serve.js +1 -0
- package/dist/index.storage.d.ts +2 -0
- package/dist/index.storage.js +2 -0
- package/dist/links/catalog.d.ts +10 -0
- package/dist/links/catalog.js +41 -0
- package/dist/links/device-oauth.d.ts +52 -0
- package/dist/links/device-oauth.js +255 -0
- package/dist/links/http.d.ts +34 -0
- package/dist/links/http.js +144 -0
- package/dist/links/service.d.ts +54 -0
- package/dist/links/service.js +578 -0
- package/dist/links/types.d.ts +6 -0
- package/dist/links/types.js +1 -0
- package/dist/mobile-remote/mobile-client-event-validator.js +5 -2
- package/dist/mobile-remote/pending-approvals.js +2 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +8 -0
- package/dist/mobile-remote/remote-host-manager.js +90 -15
- package/dist/mobile-remote/room-manager.js +17 -5
- package/dist/panels/agent-task-host.d.ts +32 -0
- package/dist/panels/agent-task-host.js +457 -0
- package/dist/panels/agent-task-models.d.ts +27 -0
- package/dist/panels/agent-task-models.js +51 -0
- package/dist/panels/agent-task-service.d.ts +105 -0
- package/dist/panels/agent-task-service.js +283 -0
- package/dist/panels/http.d.ts +34 -0
- package/dist/panels/http.js +37 -0
- package/dist/panels/hub-binding.d.ts +12 -0
- package/dist/panels/hub-binding.js +174 -0
- package/dist/panels/management-http.d.ts +29 -0
- package/dist/panels/management-http.js +119 -0
- package/dist/panels/management.d.ts +39 -0
- package/dist/panels/management.js +494 -0
- package/dist/panels/process-files.d.ts +14 -0
- package/dist/panels/process-files.js +299 -0
- package/dist/panels/process-service.d.ts +96 -0
- package/dist/panels/process-service.js +501 -0
- package/dist/panels/runtime-services.d.ts +24 -0
- package/dist/panels/runtime-services.js +462 -0
- package/dist/panels/runtime.d.ts +51 -0
- package/dist/panels/runtime.js +1063 -0
- package/dist/panels/storage-store.d.ts +9 -0
- package/dist/panels/storage-store.js +154 -0
- package/dist/panels/types.d.ts +52 -0
- package/dist/panels/types.js +1 -0
- package/dist/project-runtime/control-server.d.ts +19 -0
- package/dist/project-runtime/control-server.js +300 -0
- package/dist/project-runtime/docker-provider.d.ts +33 -0
- package/dist/project-runtime/docker-provider.js +487 -0
- package/dist/project-runtime/lock.d.ts +6 -0
- package/dist/project-runtime/lock.js +43 -0
- package/dist/project-runtime/managed-entry.d.ts +10 -0
- package/dist/project-runtime/managed-entry.js +113 -0
- package/dist/project-runtime/manager.d.ts +40 -0
- package/dist/project-runtime/manager.js +249 -0
- package/dist/project-runtime/proxy.d.ts +22 -0
- package/dist/project-runtime/proxy.js +706 -0
- package/dist/project-runtime/registry.d.ts +54 -0
- package/dist/project-runtime/registry.js +286 -0
- package/dist/project-runtime/types.d.ts +43 -0
- package/dist/project-runtime/types.js +1 -0
- package/dist/serve/cli.d.ts +5 -0
- package/dist/serve/cli.js +82 -23
- package/dist/serve/headless-server.d.ts +11 -0
- package/dist/serve/headless-server.js +737 -83
- package/dist/session-titles-store.d.ts +17 -0
- package/dist/session-titles-store.js +174 -0
- package/dist/static-files.js +1 -0
- package/dist/worker-bridge-core.js +17 -6
- package/package.json +19 -5
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
|
+
import { getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
5
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
6
|
+
import { JSONRPCMessageSchema, ListRootsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
import { buildStdioEnv, createMcpAuthenticatedFetch, localCredentialAccess, } from "@cjhyy/code-shell-core";
|
|
8
|
+
const MAX_FRAME_BYTES = 512 * 1024;
|
|
9
|
+
const MAX_TOTAL_BYTES = 2 * 1024 * 1024;
|
|
10
|
+
const MAX_PREVIEW_TOOLS = 100;
|
|
11
|
+
const MAX_TOOL_PAGES = 8;
|
|
12
|
+
class ProbeFailure extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function delay(milliseconds) {
|
|
20
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
21
|
+
}
|
|
22
|
+
/** A short-lived probe owns its process group, so a failed launcher cannot leave children behind. */
|
|
23
|
+
class BoundedStdioTransport {
|
|
24
|
+
config;
|
|
25
|
+
cwd;
|
|
26
|
+
onclose;
|
|
27
|
+
onerror;
|
|
28
|
+
onmessage;
|
|
29
|
+
child;
|
|
30
|
+
pending = Buffer.alloc(0);
|
|
31
|
+
totalBytes = 0;
|
|
32
|
+
closed = false;
|
|
33
|
+
closePromise;
|
|
34
|
+
closeNotified = false;
|
|
35
|
+
constructor(config, cwd) {
|
|
36
|
+
this.config = config;
|
|
37
|
+
this.cwd = cwd;
|
|
38
|
+
}
|
|
39
|
+
async start() {
|
|
40
|
+
if (this.closed || this.child)
|
|
41
|
+
throw new ProbeFailure("cancelled", "测试已取消。");
|
|
42
|
+
const child = spawn(this.config.command, this.config.args ?? [], {
|
|
43
|
+
cwd: this.cwd,
|
|
44
|
+
env: { ...getDefaultEnvironment(), ...buildStdioEnv(this.config.name, this.config) },
|
|
45
|
+
detached: process.platform !== "win32",
|
|
46
|
+
shell: false,
|
|
47
|
+
windowsHide: true,
|
|
48
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
49
|
+
});
|
|
50
|
+
this.child = child;
|
|
51
|
+
// Server stderr can contain access tokens. Drain it without logging or retaining it.
|
|
52
|
+
child.stderr.resume();
|
|
53
|
+
child.stdout.on("data", (chunk) => this.read(chunk));
|
|
54
|
+
child.stdout.on("error", (error) => this.onerror?.(error));
|
|
55
|
+
child.stdin.on("error", (error) => this.onerror?.(error));
|
|
56
|
+
child.once("close", () => this.notifyClose());
|
|
57
|
+
await new Promise((resolve, reject) => {
|
|
58
|
+
child.once("spawn", resolve);
|
|
59
|
+
child.once("error", (error) => {
|
|
60
|
+
reject(error);
|
|
61
|
+
this.onerror?.(error);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
read(chunk) {
|
|
66
|
+
if (this.closed)
|
|
67
|
+
return;
|
|
68
|
+
this.totalBytes += chunk.byteLength;
|
|
69
|
+
if (this.totalBytes > MAX_TOTAL_BYTES || this.pending.length + chunk.length > MAX_FRAME_BYTES) {
|
|
70
|
+
this.onerror?.(new ProbeFailure("response_too_large", "服务返回的数据过大,测试已停止。"));
|
|
71
|
+
void this.close();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
this.pending = Buffer.concat([this.pending, chunk]);
|
|
75
|
+
for (let index = this.pending.indexOf(10); index !== -1; index = this.pending.indexOf(10)) {
|
|
76
|
+
const line = this.pending.subarray(0, index).toString("utf8").trim();
|
|
77
|
+
this.pending = this.pending.subarray(index + 1);
|
|
78
|
+
if (!line)
|
|
79
|
+
continue;
|
|
80
|
+
try {
|
|
81
|
+
this.onmessage?.(JSONRPCMessageSchema.parse(JSON.parse(line)));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
this.onerror?.(new ProbeFailure("invalid_protocol", "命令的输出不是有效的 MCP 协议消息,请检查启动方式。"));
|
|
85
|
+
void this.close();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async send(message) {
|
|
91
|
+
if (this.closed || !this.child?.stdin.writable)
|
|
92
|
+
throw new ProbeFailure("closed", "服务连接已关闭。");
|
|
93
|
+
await new Promise((resolve, reject) => {
|
|
94
|
+
this.child.stdin.write(`${JSON.stringify(message)}\n`, (error) => error ? reject(error) : resolve());
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
notifyClose() {
|
|
98
|
+
if (this.closeNotified)
|
|
99
|
+
return;
|
|
100
|
+
this.closeNotified = true;
|
|
101
|
+
this.onclose?.();
|
|
102
|
+
}
|
|
103
|
+
close() {
|
|
104
|
+
if (this.closePromise)
|
|
105
|
+
return this.closePromise;
|
|
106
|
+
this.closed = true;
|
|
107
|
+
this.pending = Buffer.alloc(0);
|
|
108
|
+
this.closePromise = (async () => {
|
|
109
|
+
const child = this.child;
|
|
110
|
+
if (child) {
|
|
111
|
+
const exited = child.exitCode !== null || child.signalCode !== null;
|
|
112
|
+
const exit = exited
|
|
113
|
+
? Promise.resolve()
|
|
114
|
+
: new Promise((resolve) => child.once("close", () => resolve()));
|
|
115
|
+
const kill = (signal) => {
|
|
116
|
+
try {
|
|
117
|
+
if (process.platform !== "win32" && child.pid)
|
|
118
|
+
process.kill(-child.pid, signal);
|
|
119
|
+
else
|
|
120
|
+
child.kill(signal);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* already exited */
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
child.stdin.destroy();
|
|
127
|
+
kill("SIGTERM");
|
|
128
|
+
await Promise.race([exit, delay(250)]);
|
|
129
|
+
// The direct process can exit before one of its descendants. The group remains ours.
|
|
130
|
+
kill("SIGKILL");
|
|
131
|
+
await Promise.race([exit, delay(500)]);
|
|
132
|
+
child.stdout.destroy();
|
|
133
|
+
child.stderr.destroy();
|
|
134
|
+
}
|
|
135
|
+
this.notifyClose();
|
|
136
|
+
})();
|
|
137
|
+
return this.closePromise;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function rememberSecret(secrets, value) {
|
|
141
|
+
if (!value)
|
|
142
|
+
return;
|
|
143
|
+
secrets.add(value);
|
|
144
|
+
if (/^Bearer\s+/i.test(value))
|
|
145
|
+
secrets.add(value.replace(/^Bearer\s+/i, ""));
|
|
146
|
+
const match = /(?:token|secret|password|api[-_]?key)=([^\s]+)/i.exec(value);
|
|
147
|
+
if (match?.[1])
|
|
148
|
+
secrets.add(match[1]);
|
|
149
|
+
}
|
|
150
|
+
function redact(text, secrets, maximum) {
|
|
151
|
+
let safe = text;
|
|
152
|
+
for (const secret of [...secrets].sort((left, right) => right.length - left.length)) {
|
|
153
|
+
safe = safe.split(secret).join("[已隐藏]");
|
|
154
|
+
}
|
|
155
|
+
return safe.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "").slice(0, maximum);
|
|
156
|
+
}
|
|
157
|
+
function classify(error) {
|
|
158
|
+
if (error instanceof ProbeFailure)
|
|
159
|
+
return { code: error.code, message: error.message };
|
|
160
|
+
const status = error?.code;
|
|
161
|
+
const messages = [];
|
|
162
|
+
let current = error;
|
|
163
|
+
for (let depth = 0; current && depth < 4; depth++) {
|
|
164
|
+
messages.push(current instanceof Error ? current.message : String(current));
|
|
165
|
+
const item = current;
|
|
166
|
+
if (typeof item.code === "string")
|
|
167
|
+
messages.push(item.code);
|
|
168
|
+
current = item.cause;
|
|
169
|
+
}
|
|
170
|
+
const raw = messages.join(" ");
|
|
171
|
+
if (/ENOENT/.test(raw))
|
|
172
|
+
return {
|
|
173
|
+
code: "command_missing",
|
|
174
|
+
message: "服务器上找不到这个命令,请确认已安装并填写正确路径。",
|
|
175
|
+
};
|
|
176
|
+
if (/EACCES/.test(raw))
|
|
177
|
+
return { code: "permission_denied", message: "服务器没有执行这个命令的权限。" };
|
|
178
|
+
if (/ECONNREFUSED/.test(raw))
|
|
179
|
+
return {
|
|
180
|
+
code: "connection_refused",
|
|
181
|
+
message: "目标服务拒绝连接,请检查地址、端口和服务状态。",
|
|
182
|
+
};
|
|
183
|
+
if (/ENOTFOUND|EAI_AGAIN/.test(raw))
|
|
184
|
+
return { code: "dns", message: "无法解析服务域名,请检查地址和服务器的网络。" };
|
|
185
|
+
if (/env var .* is not set/.test(raw))
|
|
186
|
+
return {
|
|
187
|
+
code: "environment_missing",
|
|
188
|
+
message: "配置引用的服务器环境变量未设置,请检查环境变量名称和服务启动环境。",
|
|
189
|
+
};
|
|
190
|
+
if (/credential .*not found|credential .*empty|metadata is unavailable/.test(raw))
|
|
191
|
+
return {
|
|
192
|
+
code: "credential_missing",
|
|
193
|
+
message: "引用的凭据不存在或不可用,请检查服务器上的凭据配置。",
|
|
194
|
+
};
|
|
195
|
+
if (/expired|oauth.*requires/i.test(raw))
|
|
196
|
+
return {
|
|
197
|
+
code: "credential_expired",
|
|
198
|
+
message: "认证凭据已过期或需要重新授权,请在服务器上更新凭据。",
|
|
199
|
+
};
|
|
200
|
+
if (status === 401 ||
|
|
201
|
+
/\b401\b|unauthori[sz]ed|-32001|no auth provider/i.test(raw) ||
|
|
202
|
+
(error instanceof Error && error.name === "UnauthorizedError"))
|
|
203
|
+
return { code: "unauthorized", message: "服务拒绝了认证信息,请检查 API Key、请求头或凭据。" };
|
|
204
|
+
if (status === 403 || /\b403\b|forbidden/i.test(raw))
|
|
205
|
+
return { code: "forbidden", message: "当前凭据没有使用此服务的权限。" };
|
|
206
|
+
if (status === 404 || /\b404\b/.test(raw))
|
|
207
|
+
return { code: "not_found", message: "这个地址没有提供 MCP 服务,请确认完整的 MCP 接口地址。" };
|
|
208
|
+
if (status === 405 || /\b405\b|Unexpected content type|protocol|JSON/i.test(raw))
|
|
209
|
+
return { code: "protocol", message: "服务没有返回预期的 MCP 协议,请检查连接类型与接口地址。" };
|
|
210
|
+
if (/timed out|timeout|ETIMEDOUT/i.test(raw))
|
|
211
|
+
return {
|
|
212
|
+
code: "timeout",
|
|
213
|
+
message: "连接测试超时。首次运行包管理器可能需要下载依赖,可以稍后重试。",
|
|
214
|
+
};
|
|
215
|
+
if (/certificate|TLS|SSL/i.test(raw))
|
|
216
|
+
return { code: "tls", message: "服务的 HTTPS 证书验证失败,请检查证书和服务器信任配置。" };
|
|
217
|
+
return {
|
|
218
|
+
code: "connection_failed",
|
|
219
|
+
message: "连接测试失败。请确认服务支持 MCP、命令可以启动,并检查服务器上的服务日志。",
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/** Explicit, bounded sibling connection; configuration reads never invoke this function. */
|
|
223
|
+
export async function probeHubMcp(config, options) {
|
|
224
|
+
const started = Date.now();
|
|
225
|
+
const secrets = new Set();
|
|
226
|
+
for (const value of [
|
|
227
|
+
...Object.values(config.env ?? {}),
|
|
228
|
+
...Object.values(config.headers ?? {}),
|
|
229
|
+
...(config.args ?? []),
|
|
230
|
+
])
|
|
231
|
+
rememberSecret(secrets, value);
|
|
232
|
+
for (const name of [
|
|
233
|
+
...(config.envVars ?? []),
|
|
234
|
+
...Object.values(config.envHeaders ?? {}),
|
|
235
|
+
config.bearerTokenEnvVar,
|
|
236
|
+
]) {
|
|
237
|
+
if (name)
|
|
238
|
+
rememberSecret(secrets, process.env[name]);
|
|
239
|
+
}
|
|
240
|
+
if (config.url) {
|
|
241
|
+
try {
|
|
242
|
+
const url = new URL(config.url);
|
|
243
|
+
for (const value of [url.username, url.password, ...url.searchParams.values()]) {
|
|
244
|
+
rememberSecret(secrets, value);
|
|
245
|
+
try {
|
|
246
|
+
rememberSecret(secrets, decodeURIComponent(value));
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
/* The raw URL component is already hidden. */
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
/* Invalid URLs are classified below without returning the original value. */
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const controller = new AbortController();
|
|
258
|
+
const timeout = setTimeout(() => controller.abort(new ProbeFailure("timeout", "连接测试超时。首次运行包管理器可能需要下载依赖,可以稍后重试。")), options.timeoutMs ?? 10_000);
|
|
259
|
+
const cancel = () => controller.abort(new ProbeFailure("cancelled", "测试已取消。"));
|
|
260
|
+
options.signal.addEventListener("abort", cancel, { once: true });
|
|
261
|
+
if (options.signal.aborted)
|
|
262
|
+
cancel();
|
|
263
|
+
const client = new Client({ name: "code-shell-hub-probe", version: "1.0.0" }, { capabilities: { roots: {} } });
|
|
264
|
+
client.setRequestHandler(ListRootsRequestSchema, async () => ({
|
|
265
|
+
roots: [{ uri: pathToFileURL(options.cwd).href }],
|
|
266
|
+
}));
|
|
267
|
+
let transport;
|
|
268
|
+
let transportError;
|
|
269
|
+
client.onerror = (error) => {
|
|
270
|
+
transportError = error;
|
|
271
|
+
};
|
|
272
|
+
let rejectAbort;
|
|
273
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
274
|
+
rejectAbort = reject;
|
|
275
|
+
});
|
|
276
|
+
const onAbort = () => {
|
|
277
|
+
rejectAbort?.(controller.signal.reason);
|
|
278
|
+
if (transport)
|
|
279
|
+
void transport.close().catch(() => { });
|
|
280
|
+
};
|
|
281
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
282
|
+
try {
|
|
283
|
+
if (controller.signal.aborted)
|
|
284
|
+
throw controller.signal.reason;
|
|
285
|
+
const mode = config.transport ?? (config.url ? "streamable-http" : "stdio");
|
|
286
|
+
if (mode === "stdio") {
|
|
287
|
+
if (!config.command)
|
|
288
|
+
throw new ProbeFailure("invalid_config", "请先填写启动命令。");
|
|
289
|
+
transport = new BoundedStdioTransport(config, options.cwd);
|
|
290
|
+
}
|
|
291
|
+
else if (mode === "streamable-http" || mode === "sse") {
|
|
292
|
+
if (!config.url)
|
|
293
|
+
throw new ProbeFailure("invalid_config", "请先填写 MCP 接口地址。");
|
|
294
|
+
const boundedFetch = (async (input, init) => {
|
|
295
|
+
const request = new Request(input, init);
|
|
296
|
+
for (const [, value] of request.headers)
|
|
297
|
+
rememberSecret(secrets, value);
|
|
298
|
+
const signal = AbortSignal.any([request.signal, controller.signal]);
|
|
299
|
+
const response = await fetch(new Request(request, { signal, redirect: "error" }));
|
|
300
|
+
if (!response.body)
|
|
301
|
+
return response;
|
|
302
|
+
let bytes = 0;
|
|
303
|
+
const body = response.body.pipeThrough(new TransformStream({
|
|
304
|
+
transform(chunk, stream) {
|
|
305
|
+
bytes += chunk.byteLength;
|
|
306
|
+
if (bytes > MAX_TOTAL_BYTES) {
|
|
307
|
+
const error = new ProbeFailure("response_too_large", "服务返回的数据过大,测试已停止。");
|
|
308
|
+
controller.abort(error);
|
|
309
|
+
stream.error(error);
|
|
310
|
+
}
|
|
311
|
+
else
|
|
312
|
+
stream.enqueue(chunk);
|
|
313
|
+
},
|
|
314
|
+
}));
|
|
315
|
+
return new Response(body, {
|
|
316
|
+
status: response.status,
|
|
317
|
+
statusText: response.statusText,
|
|
318
|
+
headers: response.headers,
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
transport = new StreamableHTTPClientTransport(new URL(config.url), {
|
|
322
|
+
fetch: createMcpAuthenticatedFetch(config.name, config, localCredentialAccess, boundedFetch),
|
|
323
|
+
reconnectionOptions: {
|
|
324
|
+
maxRetries: 0,
|
|
325
|
+
initialReconnectionDelay: 100,
|
|
326
|
+
maxReconnectionDelay: 100,
|
|
327
|
+
reconnectionDelayGrowFactor: 1,
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
else
|
|
332
|
+
throw new ProbeFailure("unsupported_transport", "此内置服务由任务进程管理,不能创建独立测试连接。");
|
|
333
|
+
const run = (async () => {
|
|
334
|
+
await client.connect(transport);
|
|
335
|
+
let cursor;
|
|
336
|
+
let toolCount = 0;
|
|
337
|
+
const tools = [];
|
|
338
|
+
const seen = new Set();
|
|
339
|
+
for (let page = 0; page < MAX_TOOL_PAGES; page++) {
|
|
340
|
+
const result = await client.listTools(cursor ? { cursor } : {});
|
|
341
|
+
toolCount += result.tools.length;
|
|
342
|
+
for (const tool of result.tools) {
|
|
343
|
+
if (tools.length >= MAX_PREVIEW_TOOLS)
|
|
344
|
+
break;
|
|
345
|
+
tools.push({
|
|
346
|
+
name: redact(tool.name, secrets, 160),
|
|
347
|
+
...(tool.description ? { description: redact(tool.description, secrets, 600) } : {}),
|
|
348
|
+
allowed: (config.allowedTools === undefined || config.allowedTools.includes(tool.name)) &&
|
|
349
|
+
!(config.disabledTools ?? []).includes(tool.name),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
cursor = result.nextCursor;
|
|
353
|
+
if (!cursor)
|
|
354
|
+
break;
|
|
355
|
+
if (seen.has(cursor))
|
|
356
|
+
throw new ProbeFailure("invalid_protocol", "服务返回了重复的工具分页游标,测试已停止。");
|
|
357
|
+
seen.add(cursor);
|
|
358
|
+
}
|
|
359
|
+
return { toolCount, tools, truncated: !!cursor || tools.length < toolCount };
|
|
360
|
+
})();
|
|
361
|
+
const result = await Promise.race([run, aborted]);
|
|
362
|
+
return {
|
|
363
|
+
name: config.name,
|
|
364
|
+
status: "ok",
|
|
365
|
+
checkedAt: new Date().toISOString(),
|
|
366
|
+
durationMs: Date.now() - started,
|
|
367
|
+
...result,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
catch (cause) {
|
|
371
|
+
const error = classify(controller.signal.aborted
|
|
372
|
+
? controller.signal.reason
|
|
373
|
+
: transportError instanceof ProbeFailure
|
|
374
|
+
? transportError
|
|
375
|
+
: cause);
|
|
376
|
+
return {
|
|
377
|
+
name: config.name,
|
|
378
|
+
status: error.code === "cancelled" ? "cancelled" : "error",
|
|
379
|
+
checkedAt: new Date().toISOString(),
|
|
380
|
+
durationMs: Date.now() - started,
|
|
381
|
+
error,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
finally {
|
|
385
|
+
clearTimeout(timeout);
|
|
386
|
+
options.signal.removeEventListener("abort", cancel);
|
|
387
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
388
|
+
controller.abort();
|
|
389
|
+
await transport?.close().catch(() => { });
|
|
390
|
+
await client.close().catch(() => { });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** A bounded, tool-free connectivity check through the same Core clients as real tasks. */
|
|
2
|
+
import { type SettingsManager } from "@cjhyy/code-shell-core";
|
|
3
|
+
type Settings = ReturnType<SettingsManager["get"]>;
|
|
4
|
+
export type ModelProbeCode = "connected" | "missing_key" | "invalid_configuration" | "unauthorized" | "model_unavailable" | "rate_limited" | "provider_error" | "network_error" | "invalid_response" | "timeout" | "cancelled";
|
|
5
|
+
export interface ModelProbeResult {
|
|
6
|
+
ok: boolean;
|
|
7
|
+
connectionId: string;
|
|
8
|
+
model: string;
|
|
9
|
+
latencyMs: number;
|
|
10
|
+
checkedAt: string;
|
|
11
|
+
code: ModelProbeCode;
|
|
12
|
+
message: string;
|
|
13
|
+
status?: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function probeConfiguredModel(settings: Settings, id: string, options?: {
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
transport?: typeof fetch;
|
|
19
|
+
}): Promise<ModelProbeResult>;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/** A bounded, tool-free connectivity check through the same Core clients as real tasks. */
|
|
2
|
+
import { createLLMClient, resolveLLMConfigForTag, } from "@cjhyy/code-shell-core";
|
|
3
|
+
import { getMergedCatalog } from "@cjhyy/code-shell-core/internal";
|
|
4
|
+
const messages = {
|
|
5
|
+
connected: "连接成功,模型已正常返回响应。",
|
|
6
|
+
missing_key: "缺少有效的 API Key,请编辑连接并填写密钥。",
|
|
7
|
+
invalid_configuration: "连接配置不完整或不支持,请检查服务商、模型和服务地址。",
|
|
8
|
+
unauthorized: "服务商拒绝了凭据,请检查 API Key 与账号的模型访问权限。",
|
|
9
|
+
model_unavailable: "模型或接口不可用,请检查模型名称与服务地址。",
|
|
10
|
+
rate_limited: "服务商限制了请求,请检查账户额度或稍后重试。",
|
|
11
|
+
provider_error: "服务商暂时无法处理请求,请稍后重试。",
|
|
12
|
+
network_error: "无法连接服务,请检查服务器网络、服务地址与本地模型是否启动。",
|
|
13
|
+
invalid_response: "服务有响应,但没有返回有效的模型内容,请检查接口类型和模型。",
|
|
14
|
+
timeout: "连接测试超时,请检查服务地址与网络,或稍后重试。",
|
|
15
|
+
cancelled: "已取消连接测试。",
|
|
16
|
+
};
|
|
17
|
+
export async function probeConfiguredModel(settings, id, options = {}) {
|
|
18
|
+
const started = Date.now();
|
|
19
|
+
const connection = settings.modelConnections.find((item) => item.id === id && item.tag === "text");
|
|
20
|
+
const result = (code, status) => ({
|
|
21
|
+
ok: code === "connected",
|
|
22
|
+
connectionId: id,
|
|
23
|
+
model: connection?.model ?? "",
|
|
24
|
+
latencyMs: Math.max(0, Date.now() - started),
|
|
25
|
+
checkedAt: new Date().toISOString(),
|
|
26
|
+
code,
|
|
27
|
+
message: messages[code],
|
|
28
|
+
...(status ? { status } : {}),
|
|
29
|
+
});
|
|
30
|
+
if (!connection)
|
|
31
|
+
return result("invalid_configuration");
|
|
32
|
+
const template = getMergedCatalog().find((entry) => entry.id === connection.catalogId);
|
|
33
|
+
// Core treats an omitted text protocol as OpenAI-compatible.
|
|
34
|
+
if (!template)
|
|
35
|
+
return result("invalid_configuration");
|
|
36
|
+
const credential = settings.credentials.find((item) => item.id === connection.credentialId);
|
|
37
|
+
if (template.needsKey !== false && !credential?.apiKey)
|
|
38
|
+
return result("missing_key");
|
|
39
|
+
// Narrowing prevents the resolver's fallback from testing a different connection.
|
|
40
|
+
const resolved = resolveLLMConfigForTag({ ...settings, modelConnections: [connection] }, "text", id);
|
|
41
|
+
if (!resolved)
|
|
42
|
+
return result("invalid_configuration");
|
|
43
|
+
try {
|
|
44
|
+
const endpoint = new URL(resolved.baseUrl ?? "");
|
|
45
|
+
if (!["https:", "http:"].includes(endpoint.protocol) ||
|
|
46
|
+
endpoint.username ||
|
|
47
|
+
endpoint.password ||
|
|
48
|
+
endpoint.search ||
|
|
49
|
+
endpoint.hash)
|
|
50
|
+
return result("invalid_configuration");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return result("invalid_configuration");
|
|
54
|
+
}
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
const timeoutMs = Math.min(18_000, Math.max(1, options.timeoutMs ?? 18_000));
|
|
57
|
+
let timedOut = false;
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
timedOut = true;
|
|
60
|
+
controller.abort();
|
|
61
|
+
}, timeoutMs);
|
|
62
|
+
timer.unref();
|
|
63
|
+
const cancel = () => controller.abort();
|
|
64
|
+
options.signal?.addEventListener("abort", cancel, { once: true });
|
|
65
|
+
if (options.signal?.aborted)
|
|
66
|
+
controller.abort();
|
|
67
|
+
let status;
|
|
68
|
+
let transportFailed = false;
|
|
69
|
+
let invalidResponse = false;
|
|
70
|
+
let requests = 0;
|
|
71
|
+
const transport = options.transport ?? globalThis.fetch;
|
|
72
|
+
const safeFetch = (async (input, init) => {
|
|
73
|
+
// SDK retries must never turn a connectivity check into multiple paid requests.
|
|
74
|
+
if (++requests > 1)
|
|
75
|
+
return failureResponse(503);
|
|
76
|
+
try {
|
|
77
|
+
const response = await transport(input, {
|
|
78
|
+
...init,
|
|
79
|
+
redirect: "error",
|
|
80
|
+
signal: init?.signal
|
|
81
|
+
? AbortSignal.any([controller.signal, init.signal])
|
|
82
|
+
: controller.signal,
|
|
83
|
+
});
|
|
84
|
+
status = response.status;
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
await response.body?.cancel();
|
|
87
|
+
// Provider error bodies can echo credentials. Never let them reach SDK logs.
|
|
88
|
+
return failureResponse(response.status);
|
|
89
|
+
}
|
|
90
|
+
const reader = response.body?.getReader();
|
|
91
|
+
if (!reader) {
|
|
92
|
+
invalidResponse = true;
|
|
93
|
+
return failureResponse(502);
|
|
94
|
+
}
|
|
95
|
+
const chunks = [];
|
|
96
|
+
let bytes = 0;
|
|
97
|
+
try {
|
|
98
|
+
while (true) {
|
|
99
|
+
const next = await reader.read();
|
|
100
|
+
if (next.done)
|
|
101
|
+
break;
|
|
102
|
+
bytes += next.value.byteLength;
|
|
103
|
+
if (bytes > 1024 * 1024) {
|
|
104
|
+
invalidResponse = true;
|
|
105
|
+
await reader.cancel();
|
|
106
|
+
return failureResponse(502);
|
|
107
|
+
}
|
|
108
|
+
chunks.push(next.value);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
reader.releaseLock();
|
|
113
|
+
}
|
|
114
|
+
const data = Buffer.concat(chunks);
|
|
115
|
+
// Invalid JSON must not cause an SDK exception containing the response body.
|
|
116
|
+
let safeBody;
|
|
117
|
+
try {
|
|
118
|
+
const payload = JSON.parse(data.toString("utf8"));
|
|
119
|
+
safeBody = JSON.stringify(payload, (_key, value) => typeof value === "string" && resolved.apiKey
|
|
120
|
+
? value.replaceAll(resolved.apiKey, "[redacted]")
|
|
121
|
+
: value);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
invalidResponse = true;
|
|
125
|
+
return failureResponse(502);
|
|
126
|
+
}
|
|
127
|
+
return new Response(safeBody, {
|
|
128
|
+
status: response.status,
|
|
129
|
+
headers: { "content-type": "application/json", "x-should-retry": "false" },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
transportFailed = true;
|
|
134
|
+
return failureResponse(503);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
let rejectAbort;
|
|
138
|
+
try {
|
|
139
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
140
|
+
rejectAbort = () => reject(new Error("model probe aborted"));
|
|
141
|
+
controller.signal.addEventListener("abort", rejectAbort, { once: true });
|
|
142
|
+
if (controller.signal.aborted)
|
|
143
|
+
rejectAbort();
|
|
144
|
+
});
|
|
145
|
+
const request = async () => {
|
|
146
|
+
const client = await createLLMClient({ ...resolved, apiKey: resolved.apiKey ?? "codeshell-no-key", maxTokens: 32 }, { timeout: timeoutMs, retryMaxAttempts: 1, fetch: safeFetch });
|
|
147
|
+
return client.createMessage({
|
|
148
|
+
systemPrompt: "This is a connection test. Reply with OK.",
|
|
149
|
+
messages: [{ role: "user", content: "Reply OK." }],
|
|
150
|
+
tools: [],
|
|
151
|
+
reasoning: { mode: "off" },
|
|
152
|
+
maxTokens: 32,
|
|
153
|
+
requestVisible: false,
|
|
154
|
+
billingEnabled: false,
|
|
155
|
+
signal: controller.signal,
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
const response = await Promise.race([request(), aborted]);
|
|
159
|
+
return response.text?.trim() ? result("connected") : result("invalid_response");
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
if (timedOut)
|
|
163
|
+
return result("timeout");
|
|
164
|
+
if (controller.signal.aborted)
|
|
165
|
+
return result("cancelled");
|
|
166
|
+
if (invalidResponse)
|
|
167
|
+
return result("invalid_response");
|
|
168
|
+
if (transportFailed)
|
|
169
|
+
return result("network_error");
|
|
170
|
+
if (status === 401 || status === 403)
|
|
171
|
+
return result("unauthorized", status);
|
|
172
|
+
if (status === 400 || status === 404 || status === 422)
|
|
173
|
+
return result("model_unavailable", status);
|
|
174
|
+
if (status === 429)
|
|
175
|
+
return result("rate_limited", status);
|
|
176
|
+
if (status && status >= 500)
|
|
177
|
+
return result("provider_error", status);
|
|
178
|
+
return result("invalid_response", status);
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
options.signal?.removeEventListener("abort", cancel);
|
|
183
|
+
if (rejectAbort)
|
|
184
|
+
controller.signal.removeEventListener("abort", rejectAbort);
|
|
185
|
+
controller.abort();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function failureResponse(status) {
|
|
189
|
+
return Response.json({ error: { message: "Model connection check failed", type: "model_probe_error" } }, {
|
|
190
|
+
status,
|
|
191
|
+
headers: { "x-should-retry": "false" },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { WebSocket } from "ws";
|
|
2
|
+
export interface HubOutboundLimits {
|
|
3
|
+
/** A history response can contain a 32 MiB transcript plus state and live overlay. */
|
|
4
|
+
maxFrameBytes: number;
|
|
5
|
+
/** A single large history frame is allowed; additional queued frames are bounded. */
|
|
6
|
+
maxSocketBacklogBytes: number;
|
|
7
|
+
maxTotalBacklogBytes: number;
|
|
8
|
+
}
|
|
9
|
+
export type HubOutboundDropReason = "frame-too-large" | "socket-backlog" | "total-backlog" | "send-error";
|
|
10
|
+
export interface HubOutboundDrop {
|
|
11
|
+
reason: HubOutboundDropReason;
|
|
12
|
+
frameBytes: number;
|
|
13
|
+
socketBacklogBytes: number;
|
|
14
|
+
totalBacklogBytes: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Bound the WS write queue separately from the replay buffer. A disconnected
|
|
18
|
+
* browser can recover through the existing authenticated snapshot protocol;
|
|
19
|
+
* keeping an unread socket alive would retain every subsequent stream frame.
|
|
20
|
+
*/
|
|
21
|
+
export declare class HubOutboundTransport {
|
|
22
|
+
private readonly options;
|
|
23
|
+
private readonly limits;
|
|
24
|
+
private readonly dropped;
|
|
25
|
+
private readonly largeFrames;
|
|
26
|
+
constructor(options: {
|
|
27
|
+
tabs: () => Iterable<WebSocket>;
|
|
28
|
+
limits?: Partial<HubOutboundLimits>;
|
|
29
|
+
/** Diagnostic metadata only: never includes conversation contents. */
|
|
30
|
+
onDrop?: (drop: HubOutboundDrop) => void;
|
|
31
|
+
});
|
|
32
|
+
send(tab: WebSocket, line: string): boolean;
|
|
33
|
+
private backlog;
|
|
34
|
+
private totalBacklog;
|
|
35
|
+
private drop;
|
|
36
|
+
}
|