@cjhyy/code-shell-host 0.9.44 → 0.9.46

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 CHANGED
@@ -12,7 +12,7 @@ retain that path, while Panel task hosts immediately return a typed `unavailable
12
12
  decision with an explicit unsupported reason. They never turn a yes/no confirmation
13
13
  into a user answer; Core retires asynchronous questions without injecting an answer
14
14
  or starting a follow-up run. This package does not implement
15
- Protocol v2, credential storage, automation scheduling or the later service moves.
15
+ Protocol v2, credential storage, automation scheduling or output replay.
16
16
 
17
17
  Desktop bundles this package into its main process, so its dependency belongs in
18
18
  Desktop `devDependencies`. The production workspace materialization list used by
@@ -24,3 +24,45 @@ credential store, not Desktop's credential IPC/safeStorage service. Foreign
24
24
  safeStorage ciphertext fails closed; supported plaintext tags and legacy bare
25
25
  plaintext remain readable under the existing local store rules. This is not
26
26
  credential-service parity or a separate per-task vault.
27
+
28
+ The first shared services live under `services/` and are exported from this package:
29
+
30
+ - `readSkillInventory(cwd)` combines Core discovery and effective enablement; it
31
+ does not grant renderer file access or authorize mutations.
32
+ - `probeMcp(config, { cwd, signal })` owns the bounded sibling probe and closes it
33
+ on completion, cancellation or timeout. The Core internal connection owns MCP
34
+ SDK imports and stdio process groups; Host imports only Core and Node builtins.
35
+ - `probeConfiguredModel` owns Hub's existing tool-free, single-request connection
36
+ test. This is an ownership move, not a replacement for model metadata/listing.
37
+ - `readSessionFile`/`readSessionFileSync` and candidate scanners share bounded
38
+ ordinary-file reads, safe IDs and identity checks. Roots are explicit; default
39
+ scanners use Core `sessionsRoot()`. Adapters retain their catalog semantics.
40
+
41
+ Storage consumers import `@cjhyy/code-shell-host/session-files`; that entry does
42
+ not load probes, model clients or skill inventory. List-only state reads retry
43
+ at most twice after an ordinary file is atomically replaced, while single-session
44
+ authorization reads reject identity changes. Desktop catalog state is capped at
45
+ 2 MiB; oversized entries are omitted with `SESSION_LIST_STATE_TOO_LARGE`, without
46
+ logging state contents. Candidate discovery uses lstat and does not open transcripts.
47
+
48
+ MCP adapters select their stdio policy explicitly: Desktop tolerates non-JSON
49
+ launcher banners and valid JSON logger records that fail JSON-RPC validation;
50
+ Hub remains strict. Both retain byte limits, cancellation and process cleanup.
51
+ Optional HTTP GET/SSE diagnostics do not invalidate successful
52
+ POST requests. Host returns neutral error codes and bounded safe detail; adapters
53
+ choose local or server wording, and explicit credentials remain redacted.
54
+
55
+ Preview redaction treats authentication fields (including apikey/x-apikey),
56
+ envHeaders, bearerTokenEnvVar and explicit token argv as private at any length.
57
+ Other env/envVars and argv values of at least eight characters are also private,
58
+ including positional arguments; flag names are syntax. Short ordinary scalars
59
+ such as DEBUG=1, MODE=read and LOG_LEVEL=info keep tool names and descriptions
60
+ readable. Protocol/content-type headers remain ordinary text. This policy lives
61
+ in Host and applies to both adapters.
62
+
63
+ These explicit async methods do not change HostRuntime's synchronous lifecycle.
64
+ Desktop retains path grants/cache/OAuth resolution; Hub retains HTTP authority,
65
+ owner cancellation, review tokens, revision checks and mutation gates. Snapshot,
66
+ journal, output replay, UI catalog and transcript formatting remain with their
67
+ existing owners. See the [review evidence](../../../docs/todo/evidence/host-shared-services-cc-review-20261015.md)
68
+ for review state and validation limits.
package/dist/index.d.ts CHANGED
@@ -1,2 +1,6 @@
1
1
  export { HostRuntime, type HostAdapter, type HostService } from "./runtime.js";
2
+ export { SessionFileError, assertSessionFileId, readSessionFile, readSessionFileSync, readSessionListState, readSessionListStateSync, statSessionFileSync, scanSessionDirectories, scanSessionDirectoriesSync, sessionListStorage, } from "./services/session-files.js";
3
+ export { readSkillInventory } from "./services/skill-inventory.js";
4
+ export { probeConfiguredModel, type ModelProbeCode, type ModelProbeResult, } from "./services/model-connection-probe.js";
2
5
  export { classifyHostRequest, interceptHostRequest, isHostAuthorityRequest, unsupportedAskUserDecision, unsupportedHostRequestResponse, unsupportedLoopbackAnswer, unsupportedLoopbackCapability, type HostApproval, type HostRequest, type ServeHostCapabilities, } from "./host-requests.js";
6
+ export { probeMcp, mcpProbeTransport, type McpProbeResult } from "./services/mcp-probe.js";
package/dist/index.js CHANGED
@@ -1,2 +1,6 @@
1
1
  export { HostRuntime } from "./runtime.js";
2
+ export { SessionFileError, assertSessionFileId, readSessionFile, readSessionFileSync, readSessionListState, readSessionListStateSync, statSessionFileSync, scanSessionDirectories, scanSessionDirectoriesSync, sessionListStorage, } from "./services/session-files.js";
3
+ export { readSkillInventory } from "./services/skill-inventory.js";
4
+ export { probeConfiguredModel, } from "./services/model-connection-probe.js";
2
5
  export { classifyHostRequest, interceptHostRequest, isHostAuthorityRequest, unsupportedAskUserDecision, unsupportedHostRequestResponse, unsupportedLoopbackAnswer, unsupportedLoopbackCapability, } from "./host-requests.js";
6
+ export { probeMcp, mcpProbeTransport } from "./services/mcp-probe.js";
@@ -0,0 +1,2 @@
1
+ /** Filesystem custody only; importing storage must not load probes or skill inventory. */
2
+ export { SessionFileError, assertSessionFileId, readSessionFile, readSessionFileSync, readSessionListState, readSessionListStateSync, statSessionFileSync, scanSessionDirectories, scanSessionDirectoriesSync, sessionListStorage, } from "./services/session-files.js";
@@ -0,0 +1,2 @@
1
+ /** Filesystem custody only; importing storage must not load probes or skill inventory. */
2
+ export { SessionFileError, assertSessionFileId, readSessionFile, readSessionFileSync, readSessionListState, readSessionListStateSync, statSessionFileSync, scanSessionDirectories, scanSessionDirectoriesSync, sessionListStorage, } from "./services/session-files.js";
@@ -0,0 +1,36 @@
1
+ import { createMcpAuthenticatedFetch, type MCPServerConfig } from "@cjhyy/code-shell-core";
2
+ export declare function mcpProbeTransport(config: {
3
+ transport?: "stdio" | "streamable-http" | "sse";
4
+ url?: string;
5
+ }): "stdio" | "streamable-http" | "sse";
6
+ export declare function mcpProbeTransport(config: Pick<MCPServerConfig, "transport" | "url">): NonNullable<MCPServerConfig["transport"]>;
7
+ export interface McpProbeResult {
8
+ name: string;
9
+ status: "ok" | "error" | "cancelled";
10
+ checkedAt: string;
11
+ durationMs: number;
12
+ toolCount?: number;
13
+ truncated?: boolean;
14
+ tools?: {
15
+ name: string;
16
+ description?: string;
17
+ allowed: boolean;
18
+ }[];
19
+ error?: {
20
+ code: string;
21
+ message: string;
22
+ detail: string;
23
+ environment?: {
24
+ name: string;
25
+ field: string;
26
+ };
27
+ };
28
+ }
29
+ /** Explicit, bounded sibling connection; configuration reads never invoke this function. */
30
+ export declare function probeMcp(config: MCPServerConfig, options: {
31
+ cwd: string;
32
+ signal?: AbortSignal;
33
+ timeoutMs?: number;
34
+ credentialAccess?: Parameters<typeof createMcpAuthenticatedFetch>[2];
35
+ stdioPolicy?: "strict" | "skip-banner";
36
+ }): Promise<McpProbeResult>;
@@ -0,0 +1,297 @@
1
+ import { isAbsolute } from "node:path";
2
+ import { createMcpAuthenticatedFetch, localCredentialAccess, } from "@cjhyy/code-shell-core";
3
+ import { createMcpProbeConnection, McpProbeConnectionError as ProbeFailure, } from "@cjhyy/code-shell-core/internal";
4
+ const MAX_TOTAL_BYTES = 2 * 1024 * 1024;
5
+ const MAX_PREVIEW_TOOLS = 100;
6
+ const MAX_TOOL_PAGES = 8;
7
+ export function mcpProbeTransport(config) {
8
+ return config.transport ?? (config.url ? "streamable-http" : "stdio");
9
+ }
10
+ function rememberSecret(secrets, value) {
11
+ if (!value)
12
+ return;
13
+ secrets.add(value);
14
+ if (/^(Bearer|Basic|Token)\s+/i.test(value))
15
+ secrets.add(value.replace(/^(Bearer|Basic|Token)\s+/i, ""));
16
+ const match = /(?:token|secret|password|credentials?|api[-_]?key)=([^\s]+)/i.exec(value);
17
+ if (match?.[1])
18
+ secrets.add(match[1]);
19
+ }
20
+ function credentialField(name) {
21
+ const separated = name.replace(/([a-z0-9])([A-Z])/g, "$1_$2");
22
+ return /(?:^|[-_])(?:authorization|cookie|tokens?|secrets?|password|passwd|credentials?|key|auth|api[-_]?key|access[-_]?key)(?:$|[-_])/i.test(separated);
23
+ }
24
+ // Unlabelled env/argv can contain private configuration, including positional
25
+ // credentials. Keep short ordinary scalars/launcher verbs intact, but hide values
26
+ // of at least eight characters. Explicit authentication values have no minimum.
27
+ function rememberPrivateConfig(secrets, value) {
28
+ if (value && value.length >= 8)
29
+ rememberSecret(secrets, value);
30
+ }
31
+ function redact(text, secrets, maximum) {
32
+ let safe = text;
33
+ for (const secret of [...secrets].sort((left, right) => right.length - left.length)) {
34
+ safe = safe.split(secret).join("[已隐藏]");
35
+ }
36
+ return safe.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "").slice(0, maximum);
37
+ }
38
+ function classify(error) {
39
+ if (error instanceof ProbeFailure)
40
+ return { code: error.code, message: error.message };
41
+ const status = error?.code;
42
+ const messages = [];
43
+ let current = error;
44
+ for (let depth = 0; current && depth < 4; depth++) {
45
+ messages.push(current instanceof Error ? current.message : String(current));
46
+ const item = current;
47
+ if (typeof item.code === "string")
48
+ messages.push(item.code);
49
+ current = item.cause;
50
+ }
51
+ const raw = messages.join(" ");
52
+ if (/ENOENT/.test(raw))
53
+ return {
54
+ code: "command_missing",
55
+ message: "找不到这个命令,请确认已安装并填写正确路径。",
56
+ };
57
+ if (/EACCES/.test(raw))
58
+ return { code: "permission_denied", message: "没有执行这个命令的权限。" };
59
+ if (/ECONNREFUSED/.test(raw))
60
+ return {
61
+ code: "connection_refused",
62
+ message: "目标服务拒绝连接,请检查地址、端口和服务状态。",
63
+ };
64
+ if (/ENOTFOUND|EAI_AGAIN/.test(raw))
65
+ return { code: "dns", message: "无法解析服务域名,请检查地址和当前网络。" };
66
+ if (/env var .* is not set/.test(raw))
67
+ return {
68
+ code: "environment_missing",
69
+ message: "配置引用的环境变量未设置,请检查环境变量名称和服务启动环境。",
70
+ };
71
+ if (/credential .*not found|credential .*empty|metadata is unavailable/.test(raw))
72
+ return {
73
+ code: "credential_missing",
74
+ message: "引用的凭据不存在或不可用,请检查凭据配置。",
75
+ };
76
+ if (/expired|oauth.*requires/i.test(raw))
77
+ return {
78
+ code: "credential_expired",
79
+ message: "认证凭据已过期或需要重新授权,请更新凭据。",
80
+ };
81
+ if (status === 401 ||
82
+ /\b401\b|unauthori[sz]ed|-32001|no auth provider/i.test(raw) ||
83
+ (error instanceof Error && error.name === "UnauthorizedError"))
84
+ return { code: "unauthorized", message: "服务拒绝了认证信息,请检查 API Key、请求头或凭据。" };
85
+ if (status === 403 || /\b403\b|forbidden/i.test(raw))
86
+ return { code: "forbidden", message: "当前凭据没有使用此服务的权限。" };
87
+ if (status === 404 || /\b404\b/.test(raw))
88
+ return { code: "not_found", message: "这个地址没有提供 MCP 服务,请确认完整的 MCP 接口地址。" };
89
+ if (status === 405 || /\b405\b|Unexpected content type|protocol|JSON/i.test(raw))
90
+ return { code: "protocol", message: "服务没有返回预期的 MCP 协议,请检查连接类型与接口地址。" };
91
+ if (/timed out|timeout|ETIMEDOUT/i.test(raw))
92
+ return {
93
+ code: "timeout",
94
+ message: "连接测试超时。首次运行包管理器可能需要下载依赖,可以稍后重试。",
95
+ };
96
+ if (/certificate|TLS|SSL/i.test(raw))
97
+ return { code: "tls", message: "服务的 HTTPS 证书验证失败,请检查证书和信任配置。" };
98
+ return {
99
+ code: "connection_failed",
100
+ message: "连接测试失败。请确认服务支持 MCP、命令可以启动,并检查服务日志。",
101
+ };
102
+ }
103
+ /** Explicit, bounded sibling connection; configuration reads never invoke this function. */
104
+ export async function probeMcp(config, options) {
105
+ if (!isAbsolute(options.cwd))
106
+ throw new Error("MCP probe requires an absolute workspace");
107
+ config = structuredClone(config);
108
+ const started = Date.now();
109
+ const secrets = new Set();
110
+ for (const [name, value] of Object.entries(config.env ?? {})) {
111
+ if (credentialField(name))
112
+ rememberSecret(secrets, value);
113
+ else
114
+ rememberPrivateConfig(secrets, value);
115
+ }
116
+ const secretHeader = (name) => credentialField(name) ||
117
+ Object.keys(config.envHeaders ?? {}).some((header) => header.toLowerCase() === name.toLowerCase());
118
+ for (const [name, value] of Object.entries(config.headers ?? {}))
119
+ if (secretHeader(name))
120
+ rememberSecret(secrets, value);
121
+ // Flags are syntax; their explicit authentication values are always private.
122
+ // Other argv values follow the same conservative length rule as untyped env.
123
+ const args = config.args ?? [];
124
+ for (let index = 0; index < args.length; index++) {
125
+ const flag = /^--?([^=]+)(?:=(.*))?$/.exec(args[index]);
126
+ if (flag && credentialField(flag[1]))
127
+ rememberSecret(secrets, flag[2] ?? args[index + 1]);
128
+ if (!args[index].startsWith("-"))
129
+ rememberPrivateConfig(secrets, args[index]);
130
+ else if (args[index].includes("="))
131
+ rememberPrivateConfig(secrets, args[index].slice(args[index].indexOf("=") + 1));
132
+ const bearer = /\bBearer\s+(.+)/i.exec(args[index]);
133
+ if (bearer)
134
+ rememberSecret(secrets, bearer[1]);
135
+ }
136
+ for (const name of config.envVars ?? []) {
137
+ if (credentialField(name))
138
+ rememberSecret(secrets, process.env[name]);
139
+ else
140
+ rememberPrivateConfig(secrets, process.env[name]);
141
+ }
142
+ for (const name of [...Object.values(config.envHeaders ?? {}), config.bearerTokenEnvVar]) {
143
+ if (name)
144
+ rememberSecret(secrets, process.env[name]);
145
+ }
146
+ if (config.url) {
147
+ try {
148
+ const url = new URL(config.url);
149
+ const privateQueryValues = [...url.searchParams]
150
+ .filter(([name]) => /token|secret|password|credential|key|auth|^(access|code|sig|signature)$/i.test(name))
151
+ .map(([, value]) => value);
152
+ for (const value of [url.username, url.password, ...privateQueryValues]) {
153
+ rememberSecret(secrets, value);
154
+ try {
155
+ rememberSecret(secrets, decodeURIComponent(value));
156
+ }
157
+ catch {
158
+ /* The raw URL component is already hidden. */
159
+ }
160
+ }
161
+ }
162
+ catch {
163
+ /* Invalid URLs are classified below without returning the original value. */
164
+ }
165
+ }
166
+ const controller = new AbortController();
167
+ const timeout = setTimeout(() => controller.abort(new ProbeFailure("timeout", "连接测试超时。首次运行包管理器可能需要下载依赖,可以稍后重试。")), options.timeoutMs ?? 10_000);
168
+ const cancel = () => controller.abort(new ProbeFailure("cancelled", "测试已取消。"));
169
+ options.signal?.addEventListener("abort", cancel, { once: true });
170
+ if (options.signal?.aborted)
171
+ cancel();
172
+ let connection;
173
+ let rejectAbort;
174
+ const aborted = new Promise((_resolve, reject) => {
175
+ rejectAbort = reject;
176
+ });
177
+ const onAbort = () => {
178
+ rejectAbort?.(controller.signal.reason);
179
+ if (connection)
180
+ void connection.close().catch(() => { });
181
+ };
182
+ controller.signal.addEventListener("abort", onAbort, { once: true });
183
+ try {
184
+ if (controller.signal.aborted)
185
+ throw controller.signal.reason;
186
+ const mode = mcpProbeTransport(config);
187
+ if (!["stdio", "streamable-http", "sse"].includes(mode))
188
+ throw new ProbeFailure("unsupported_transport", "此内置服务由任务进程管理,不能创建独立测试连接。");
189
+ if (mode === "stdio" && !config.command)
190
+ throw new ProbeFailure("invalid_config", "请先填写启动命令。");
191
+ if (mode !== "stdio" && !config.url)
192
+ throw new ProbeFailure("invalid_config", "请先填写 MCP 接口地址。");
193
+ let totalBytes = 0;
194
+ const boundedFetch = (async (input, init) => {
195
+ const request = new Request(input, init);
196
+ for (const [name, value] of request.headers)
197
+ if (secretHeader(name))
198
+ rememberSecret(secrets, value);
199
+ const signal = AbortSignal.any([request.signal, controller.signal]);
200
+ const response = await fetch(new Request(request, { signal, redirect: "error" }));
201
+ if (!response.body)
202
+ return response;
203
+ const body = response.body.pipeThrough(new TransformStream({
204
+ transform(chunk, stream) {
205
+ totalBytes += chunk.byteLength;
206
+ if (totalBytes > MAX_TOTAL_BYTES) {
207
+ const error = new ProbeFailure("response_too_large", "服务返回的数据过大,测试已停止。");
208
+ controller.abort(error);
209
+ stream.error(error);
210
+ }
211
+ else
212
+ stream.enqueue(chunk);
213
+ },
214
+ }));
215
+ return new Response(body, {
216
+ status: response.status,
217
+ statusText: response.statusText,
218
+ headers: response.headers,
219
+ });
220
+ });
221
+ connection = createMcpProbeConnection({ ...config, transport: mode }, {
222
+ cwd: options.cwd,
223
+ stdioPolicy: options.stdioPolicy,
224
+ fetch: createMcpAuthenticatedFetch(config.name, config, options.credentialAccess ?? localCredentialAccess, boundedFetch),
225
+ });
226
+ const run = (async () => {
227
+ await connection.connect();
228
+ let cursor;
229
+ let toolCount = 0;
230
+ const tools = [];
231
+ const seen = new Set();
232
+ for (let page = 0; page < MAX_TOOL_PAGES; page++) {
233
+ const result = await connection.listTools(cursor);
234
+ toolCount += result.tools.length;
235
+ for (const tool of result.tools) {
236
+ if (tools.length >= MAX_PREVIEW_TOOLS)
237
+ break;
238
+ tools.push({
239
+ name: redact(tool.name, secrets, 160),
240
+ ...(tool.description ? { description: redact(tool.description, secrets, 600) } : {}),
241
+ allowed: (config.allowedTools === undefined || config.allowedTools.includes(tool.name)) &&
242
+ !(config.disabledTools ?? []).includes(tool.name),
243
+ });
244
+ }
245
+ cursor = result.nextCursor;
246
+ if (!cursor)
247
+ break;
248
+ if (seen.has(cursor))
249
+ throw new ProbeFailure("invalid_protocol", "服务返回了重复的工具分页游标,测试已停止。");
250
+ seen.add(cursor);
251
+ }
252
+ return { toolCount, tools, truncated: !!cursor || tools.length < toolCount };
253
+ })();
254
+ const result = await Promise.race([run, aborted]);
255
+ return {
256
+ name: config.name,
257
+ status: "ok",
258
+ checkedAt: new Date().toISOString(),
259
+ durationMs: Date.now() - started,
260
+ ...result,
261
+ };
262
+ }
263
+ catch (cause) {
264
+ const failure = controller.signal.aborted ? controller.signal.reason : cause;
265
+ const classified = classify(failure);
266
+ const raw = failure instanceof Error ? failure.message : "";
267
+ const missing = /env var "([A-Za-z_][A-Za-z0-9_]{0,127})" \(from (envVars|envHeaders|bearerTokenEnvVar)\) is not set/.exec(raw);
268
+ const referenced = missing &&
269
+ (missing[2] === "envVars"
270
+ ? config.envVars?.includes(missing[1])
271
+ : missing[2] === "envHeaders"
272
+ ? Object.values(config.envHeaders ?? {}).includes(missing[1])
273
+ : config.bearerTokenEnvVar === missing[1]);
274
+ const environment = referenced && missing ? { name: missing[1], field: missing[2] } : undefined;
275
+ // SDK/provider error bodies can contain unrelated secrets. Never return raw
276
+ // stacks/bodies; expose bounded technical context and validated config names.
277
+ const error = {
278
+ ...classified,
279
+ detail: redact(`MCP probe: code=${classified.code}; transport=${mcpProbeTransport(config)}${environment ? `; env var "${environment.name}" (from ${environment.field}) is not set` : ""}`, secrets, 1200),
280
+ ...(environment ? { environment } : {}),
281
+ };
282
+ return {
283
+ name: config.name,
284
+ status: error.code === "cancelled" ? "cancelled" : "error",
285
+ checkedAt: new Date().toISOString(),
286
+ durationMs: Date.now() - started,
287
+ error,
288
+ };
289
+ }
290
+ finally {
291
+ clearTimeout(timeout);
292
+ options.signal?.removeEventListener("abort", cancel);
293
+ controller.signal.removeEventListener("abort", onAbort);
294
+ controller.abort();
295
+ await connection?.close().catch(() => { });
296
+ }
297
+ }
@@ -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,31 @@
1
+ import * as fs from "node:fs";
2
+ export declare class SessionFileError extends Error {
3
+ readonly code: "invalid_path" | "forbidden" | "too_large" | "changed";
4
+ readonly subject?: "directory" | "file" | undefined;
5
+ constructor(code: "invalid_path" | "forbidden" | "too_large" | "changed", message: string, subject?: "directory" | "file" | undefined);
6
+ }
7
+ export declare function assertSessionFileId(id: string): void;
8
+ export declare function readSessionFileSync(root: string, id: string, leaf: string, maximumBytes: number): string;
9
+ /** Lists tolerate at most two ordinary-file atomic replacements, never directory changes. */
10
+ export declare function readSessionListStateSync(root: string, id: string, maximumBytes: number): string;
11
+ export declare function readSessionListState(root: string, id: string, maximumBytes: number): Promise<string>;
12
+ export declare function statSessionFileSync(root: string, id: string, leaf: string, maximumBytes?: number): fs.Stats;
13
+ /** Async counterpart for Electron main; never blocks its event loop on disk reads. */
14
+ export declare function readSessionFile(root: string, id: string, leaf: string, maximumBytes: number): Promise<string>;
15
+ export declare function scanSessionDirectoriesSync(root?: string): {
16
+ id: string;
17
+ mtime: number;
18
+ }[];
19
+ export declare function scanSessionDirectories(root?: string): Promise<{
20
+ id: string;
21
+ mtime: number;
22
+ }[]>;
23
+ /** Only filesystem custody. Core retains activity ordering, filters, cursor and tail previews. */
24
+ export declare function sessionListStorage(root: string): {
25
+ candidates: () => {
26
+ dir: string;
27
+ lastActiveAt: number;
28
+ transcriptExists: boolean;
29
+ }[];
30
+ readState: (id: string, maximumBytes: number) => string;
31
+ };
@@ -0,0 +1,252 @@
1
+ import * as fs from "node:fs";
2
+ import { promises as fsp } from "node:fs";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+ import { sessionsRoot } from "@cjhyy/code-shell-core";
5
+ export class SessionFileError extends Error {
6
+ code;
7
+ subject;
8
+ constructor(code, message, subject) {
9
+ super(message);
10
+ this.code = code;
11
+ this.subject = subject;
12
+ }
13
+ }
14
+ export function assertSessionFileId(id) {
15
+ if (!/^[A-Za-z0-9_.-]{1,128}$/.test(id) || id === "." || id.includes(".."))
16
+ throw new SessionFileError("invalid_path", "Invalid session id");
17
+ }
18
+ function paths(root, id, leaf) {
19
+ assertSessionFileId(id);
20
+ if (!isAbsolute(root) ||
21
+ !/^[A-Za-z0-9_.-]{1,128}$/.test(leaf) ||
22
+ leaf === "." ||
23
+ leaf.includes(".."))
24
+ throw new SessionFileError("invalid_path", "Session files require an absolute root and a direct filename");
25
+ return { directory: join(root, id), file: join(root, id, leaf) };
26
+ }
27
+ function directory(info) {
28
+ if (!info.isDirectory() || info.isSymbolicLink())
29
+ throw new SessionFileError("forbidden", "Session directory is not ordinary");
30
+ return info;
31
+ }
32
+ function ordinary(info, limit) {
33
+ if (!info.isFile() || info.isSymbolicLink())
34
+ throw new SessionFileError("forbidden", "Session record is not an ordinary file");
35
+ if (!Number.isSafeInteger(limit) || limit < 0 || info.size > limit)
36
+ throw new SessionFileError("too_large", "Session record exceeds the read limit");
37
+ return info;
38
+ }
39
+ function same(before, after, subject = "directory") {
40
+ if (after.isSymbolicLink() || before.dev !== after.dev || before.ino !== after.ino)
41
+ throw new SessionFileError("changed", "Session storage changed during read", subject);
42
+ }
43
+ const directoryFlags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_DIRECTORY ?? 0);
44
+ const fileFlags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0);
45
+ function withFileSync(root, id, leaf, limit, read) {
46
+ const path = paths(root, id, leaf);
47
+ const before = directory(fs.lstatSync(path.directory));
48
+ let dir, file;
49
+ try {
50
+ dir = fs.openSync(path.directory, directoryFlags);
51
+ same(before, fs.fstatSync(dir));
52
+ file = fs.openSync(process.platform === "linux" ? `/proc/self/fd/${dir}/${leaf}` : path.file, fileFlags);
53
+ const info = ordinary(fs.fstatSync(file), limit);
54
+ const value = read(file, info);
55
+ same(before, fs.lstatSync(path.directory));
56
+ same(info, fs.lstatSync(path.file), "file");
57
+ return value;
58
+ }
59
+ finally {
60
+ try {
61
+ if (file !== undefined)
62
+ fs.closeSync(file);
63
+ }
64
+ finally {
65
+ if (dir !== undefined)
66
+ fs.closeSync(dir);
67
+ }
68
+ }
69
+ }
70
+ export function readSessionFileSync(root, id, leaf, maximumBytes) {
71
+ return withFileSync(root, id, leaf, maximumBytes, (fd, info) => {
72
+ const data = Buffer.alloc(info.size);
73
+ let offset = 0;
74
+ while (offset < data.length) {
75
+ const bytes = fs.readSync(fd, data, offset, data.length - offset, offset);
76
+ if (!bytes)
77
+ break;
78
+ offset += bytes;
79
+ }
80
+ return data.subarray(0, offset).toString("utf8");
81
+ });
82
+ }
83
+ /** Lists tolerate at most two ordinary-file atomic replacements, never directory changes. */
84
+ export function readSessionListStateSync(root, id, maximumBytes) {
85
+ const path = paths(root, id, "state.json");
86
+ const anchor = directory(fs.lstatSync(path.directory));
87
+ const fd = fs.openSync(path.directory, directoryFlags);
88
+ try {
89
+ same(anchor, fs.fstatSync(fd));
90
+ for (let attempt = 0;; attempt++) {
91
+ try {
92
+ const value = readSessionFileSync(root, id, "state.json", maximumBytes);
93
+ same(anchor, fs.lstatSync(path.directory));
94
+ return value;
95
+ }
96
+ catch (error) {
97
+ if (!(error instanceof SessionFileError) ||
98
+ error.code !== "changed" ||
99
+ error.subject !== "file")
100
+ throw error;
101
+ same(anchor, fs.lstatSync(path.directory));
102
+ ordinary(fs.lstatSync(path.file), maximumBytes);
103
+ if (attempt >= 2)
104
+ throw error;
105
+ }
106
+ }
107
+ }
108
+ finally {
109
+ fs.closeSync(fd);
110
+ }
111
+ }
112
+ export async function readSessionListState(root, id, maximumBytes) {
113
+ const path = paths(root, id, "state.json");
114
+ const anchor = directory(await fsp.lstat(path.directory));
115
+ const handle = await fsp.open(path.directory, directoryFlags);
116
+ try {
117
+ same(anchor, await handle.stat());
118
+ for (let attempt = 0;; attempt++) {
119
+ try {
120
+ const value = await readSessionFile(root, id, "state.json", maximumBytes);
121
+ same(anchor, await fsp.lstat(path.directory));
122
+ return value;
123
+ }
124
+ catch (error) {
125
+ if (!(error instanceof SessionFileError) ||
126
+ error.code !== "changed" ||
127
+ error.subject !== "file")
128
+ throw error;
129
+ same(anchor, await fsp.lstat(path.directory));
130
+ ordinary(await fsp.lstat(path.file), maximumBytes);
131
+ if (attempt >= 2)
132
+ throw error;
133
+ }
134
+ }
135
+ }
136
+ finally {
137
+ await handle.close();
138
+ }
139
+ }
140
+ export function statSessionFileSync(root, id, leaf, maximumBytes = Number.MAX_SAFE_INTEGER) {
141
+ return withFileSync(root, id, leaf, maximumBytes, (_fd, info) => info);
142
+ }
143
+ /** Async counterpart for Electron main; never blocks its event loop on disk reads. */
144
+ export async function readSessionFile(root, id, leaf, maximumBytes) {
145
+ const path = paths(root, id, leaf);
146
+ const before = directory(await fsp.lstat(path.directory));
147
+ let dir, file;
148
+ try {
149
+ dir = await fsp.open(path.directory, directoryFlags);
150
+ same(before, await dir.stat());
151
+ file = await fsp.open(process.platform === "linux" ? `/proc/self/fd/${dir.fd}/${leaf}` : path.file, fileFlags);
152
+ const info = ordinary(await file.stat(), maximumBytes);
153
+ const data = Buffer.alloc(info.size);
154
+ let offset = 0;
155
+ while (offset < data.length) {
156
+ const { bytesRead } = await file.read(data, offset, data.length - offset, offset);
157
+ if (!bytesRead)
158
+ break;
159
+ offset += bytesRead;
160
+ }
161
+ same(before, await fsp.lstat(path.directory));
162
+ same(info, await fsp.lstat(path.file), "file");
163
+ return data.subarray(0, offset).toString("utf8");
164
+ }
165
+ finally {
166
+ try {
167
+ await file?.close();
168
+ }
169
+ finally {
170
+ await dir?.close();
171
+ }
172
+ }
173
+ }
174
+ export function scanSessionDirectoriesSync(root = resolve(sessionsRoot())) {
175
+ if (!isAbsolute(root))
176
+ throw new SessionFileError("invalid_path", "Session root must be absolute");
177
+ try {
178
+ return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
179
+ try {
180
+ if (!entry.isDirectory())
181
+ return [];
182
+ assertSessionFileId(entry.name);
183
+ const info = directory(fs.lstatSync(join(root, entry.name)));
184
+ return [{ id: entry.name, mtime: info.mtimeMs }];
185
+ }
186
+ catch {
187
+ return [];
188
+ }
189
+ });
190
+ }
191
+ catch (error) {
192
+ if (error.code === "ENOENT")
193
+ return [];
194
+ throw error;
195
+ }
196
+ }
197
+ export async function scanSessionDirectories(root = resolve(sessionsRoot())) {
198
+ if (!isAbsolute(root))
199
+ throw new SessionFileError("invalid_path", "Session root must be absolute");
200
+ try {
201
+ const entries = await fsp.readdir(root, { withFileTypes: true });
202
+ const candidates = await Promise.all(entries.map(async (entry) => {
203
+ try {
204
+ if (!entry.isDirectory())
205
+ return [];
206
+ assertSessionFileId(entry.name);
207
+ const info = directory(await fsp.lstat(join(root, entry.name)));
208
+ return [{ id: entry.name, mtime: info.mtimeMs }];
209
+ }
210
+ catch {
211
+ return [];
212
+ }
213
+ }));
214
+ return candidates.flat();
215
+ }
216
+ catch (error) {
217
+ if (error.code === "ENOENT")
218
+ return [];
219
+ throw error;
220
+ }
221
+ }
222
+ /** Only filesystem custody. Core retains activity ordering, filters, cursor and tail previews. */
223
+ export function sessionListStorage(root) {
224
+ return {
225
+ candidates: () => scanSessionDirectoriesSync(root).flatMap(({ id }) => {
226
+ if (id.startsWith(".pending-") || id.startsWith("qchat-"))
227
+ return [];
228
+ try {
229
+ const state = ordinary(fs.lstatSync(join(root, id, "state.json")), 1024 * 1024);
230
+ let transcript;
231
+ try {
232
+ transcript = ordinary(fs.lstatSync(join(root, id, "transcript.jsonl")), Number.MAX_SAFE_INTEGER);
233
+ }
234
+ catch (error) {
235
+ if (!["ENOENT", "EACCES", "EPERM"].includes(error.code ?? ""))
236
+ throw error;
237
+ }
238
+ return [
239
+ {
240
+ dir: id,
241
+ lastActiveAt: transcript?.mtimeMs ?? state.mtimeMs,
242
+ transcriptExists: !!transcript,
243
+ },
244
+ ];
245
+ }
246
+ catch {
247
+ return [];
248
+ }
249
+ }),
250
+ readState: (id, maximumBytes) => readSessionListStateSync(root, id, maximumBytes),
251
+ };
252
+ }
@@ -0,0 +1,12 @@
1
+ /** Discovery and effective toggles only. Grants and mutations belong to the adapter. */
2
+ export declare function readSkillInventory(cwd: string, options?: {
3
+ refresh?: boolean;
4
+ }): {
5
+ disabledSkills: string[];
6
+ disabledPlugins: string[];
7
+ disabledPluginHooks: string[];
8
+ skills: {
9
+ definition: import("@cjhyy/code-shell-core").SkillDefinition;
10
+ enabled: boolean;
11
+ }[];
12
+ };
@@ -0,0 +1,18 @@
1
+ import { SettingsManager, scanSkills, invalidateSkillCache } from "@cjhyy/code-shell-core";
2
+ import { computeEffectiveDisabledLists } from "@cjhyy/code-shell-core/internal";
3
+ import { isAbsolute } from "node:path";
4
+ /** Discovery and effective toggles only. Grants and mutations belong to the adapter. */
5
+ export function readSkillInventory(cwd, options = {}) {
6
+ if (!isAbsolute(cwd))
7
+ throw new Error("Skill inventory requires an absolute workspace");
8
+ if (options.refresh)
9
+ invalidateSkillCache();
10
+ const disabled = computeEffectiveDisabledLists(new SettingsManager(cwd, "full"), cwd);
11
+ const enabledPaths = new Set(scanSkills(cwd, disabled).map((skill) => skill.filePath));
12
+ // Keep project binding even when showing individually disabled skills/plugins.
13
+ const skills = scanSkills(cwd).map((definition) => ({
14
+ definition,
15
+ enabled: enabledPaths.has(definition.filePath),
16
+ }));
17
+ return { skills, ...disabled };
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-host",
3
- "version": "0.9.44",
3
+ "version": "0.9.46",
4
4
  "description": "Host-neutral capability registry and worker request dispatch for CodeShell hosts.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -9,6 +9,10 @@
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
11
  "import": "./dist/index.js"
12
+ },
13
+ "./session-files": {
14
+ "types": "./dist/index.session-files.d.ts",
15
+ "import": "./dist/index.session-files.js"
12
16
  }
13
17
  },
14
18
  "files": [
@@ -21,7 +25,7 @@
21
25
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
22
26
  },
23
27
  "dependencies": {
24
- "@cjhyy/code-shell-core": "0.9.44"
28
+ "@cjhyy/code-shell-core": "0.9.46"
25
29
  },
26
30
  "engines": {
27
31
  "node": ">=20.10"