@cjhyy/code-shell-core 0.6.0-rc.8 → 0.7.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THIRD_PARTY_NOTICES.md +206 -0
- package/dist/automation/scheduler.d.ts +13 -7
- package/dist/automation/scheduler.js +116 -37
- package/dist/capability-control/service.d.ts +2 -0
- package/dist/capability-control/service.js +4 -2
- package/dist/cc-orchestrator/agent-adapter.d.ts +2 -0
- package/dist/cc-orchestrator/agent-adapter.js +7 -1
- package/dist/cc-orchestrator/codex-session-history.js +1 -1
- package/dist/cc-orchestrator/cwd-normalize.d.ts +2 -0
- package/dist/cc-orchestrator/cwd-normalize.js +19 -0
- package/dist/cc-orchestrator/external-agent-bindings.d.ts +27 -0
- package/dist/cc-orchestrator/external-agent-bindings.js +150 -0
- package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -0
- package/dist/cc-orchestrator/external-agent-driver.js +102 -51
- package/dist/cc-orchestrator/external-agent-session-store.d.ts +23 -0
- package/dist/cc-orchestrator/external-agent-session-store.js +146 -0
- package/dist/cc-orchestrator/session-history.js +2 -2
- package/dist/cli/agent-server-stdio.js +9 -2
- package/dist/context/compaction.d.ts +38 -1
- package/dist/context/compaction.js +138 -0
- package/dist/context/manager.d.ts +34 -2
- package/dist/context/manager.js +254 -58
- package/dist/context/token-counter.js +13 -0
- package/dist/credentials/access.d.ts +56 -0
- package/dist/credentials/access.js +183 -0
- package/dist/credentials/index.d.ts +1 -0
- package/dist/credentials/index.js +1 -0
- package/dist/credentials/inject-credential-tool.d.ts +3 -1
- package/dist/credentials/inject-credential-tool.js +30 -11
- package/dist/credentials/types.d.ts +2 -2
- package/dist/credentials/use-credential-tool.d.ts +9 -1
- package/dist/credentials/use-credential-tool.js +58 -45
- package/dist/engine/engine.d.ts +38 -12
- package/dist/engine/engine.js +477 -163
- package/dist/engine/image-policy.d.ts +6 -0
- package/dist/engine/image-policy.js +17 -6
- package/dist/engine/input-attachments.d.ts +13 -0
- package/dist/engine/input-attachments.js +255 -0
- package/dist/engine/model-facade.d.ts +5 -2
- package/dist/engine/model-facade.js +6 -16
- package/dist/engine/parse-task.d.ts +10 -0
- package/dist/engine/parse-task.js +5 -0
- package/dist/engine/query.js +2 -0
- package/dist/engine/session-usage.d.ts +12 -0
- package/dist/engine/session-usage.js +56 -0
- package/dist/engine/steer-queue.d.ts +2 -1
- package/dist/engine/steer-queue.js +2 -2
- package/dist/engine/streaming-tool-queue.d.ts +11 -7
- package/dist/engine/streaming-tool-queue.js +11 -7
- package/dist/engine/turn-loop.d.ts +34 -2
- package/dist/engine/turn-loop.js +269 -52
- package/dist/engine/types.d.ts +8 -0
- package/dist/git/worktree/crud.d.ts +69 -0
- package/dist/git/worktree/crud.js +206 -0
- package/dist/git/worktree/diff.d.ts +14 -0
- package/dist/git/worktree/diff.js +82 -0
- package/dist/git/worktree/git-exec.d.ts +7 -0
- package/dist/git/worktree/git-exec.js +51 -0
- package/dist/git/worktree/index.d.ts +5 -0
- package/dist/git/worktree/index.js +5 -0
- package/dist/git/worktree/query.d.ts +42 -0
- package/dist/git/worktree/query.js +121 -0
- package/dist/git/worktree/slug.d.ts +11 -0
- package/dist/git/worktree/slug.js +58 -0
- package/dist/git/worktree.d.ts +1 -84
- package/dist/git/worktree.js +5 -230
- package/dist/index.d.ts +31 -28
- package/dist/index.js +27 -25
- package/dist/logging/logger.js +6 -6
- package/dist/logging/sanitize-messages.d.ts +10 -2
- package/dist/logging/sanitize-messages.js +21 -6
- package/dist/plugins/installer/checkUpdate.d.ts +4 -1
- package/dist/plugins/installer/checkUpdate.js +4 -2
- package/dist/plugins/installer/install.js +2 -0
- package/dist/plugins/installer/installFromSource.js +9 -1
- package/dist/plugins/installer/parseSource.d.ts +4 -1
- package/dist/plugins/installer/parseSource.js +28 -10
- package/dist/plugins/installer/sourcePath.d.ts +9 -0
- package/dist/plugins/installer/sourcePath.js +50 -0
- package/dist/plugins/installer/update.d.ts +4 -1
- package/dist/plugins/installer/update.js +5 -3
- package/dist/plugins/parseMarketplaceInput.d.ts +4 -1
- package/dist/plugins/parseMarketplaceInput.js +4 -3
- package/dist/plugins/pluginInstaller.js +24 -22
- package/dist/preset/index.d.ts +1 -0
- package/dist/preset/index.js +16 -9
- package/dist/prompt/sections/base.md +1 -1
- package/dist/protocol/chat-session-manager.d.ts +2 -0
- package/dist/protocol/chat-session-manager.js +38 -2
- package/dist/protocol/chat-session.d.ts +5 -0
- package/dist/protocol/chat-session.js +2 -0
- package/dist/protocol/client.d.ts +13 -5
- package/dist/protocol/client.js +26 -3
- package/dist/protocol/server.d.ts +43 -12
- package/dist/protocol/server.js +326 -104
- package/dist/protocol/types.d.ts +51 -0
- package/dist/protocol/types.js +6 -0
- package/dist/run/FileRunStore.js +10 -1
- package/dist/run/Heartbeat.js +12 -0
- package/dist/run/RunApprovalBackend.d.ts +3 -0
- package/dist/run/RunApprovalBackend.js +41 -6
- package/dist/run/RunLock.js +2 -0
- package/dist/run/RunManager.d.ts +2 -0
- package/dist/run/RunManager.js +64 -24
- package/dist/run/ids.d.ts +2 -0
- package/dist/run/ids.js +23 -0
- package/dist/runtime/background-shell.d.ts +1 -0
- package/dist/runtime/background-shell.js +23 -13
- package/dist/runtime/safe-spawn.js +74 -11
- package/dist/runtime/spawn-common.js +10 -0
- package/dist/services/auto-dream.d.ts +15 -4
- package/dist/services/auto-dream.js +20 -20
- package/dist/services/dream-consolidation.d.ts +2 -3
- package/dist/services/dream-consolidation.js +65 -15
- package/dist/services/extract-memories.d.ts +14 -5
- package/dist/services/extract-memories.js +20 -3
- package/dist/services/global-dream-promotion.d.ts +23 -0
- package/dist/services/global-dream-promotion.js +112 -0
- package/dist/services/memory-orchestrator.js +347 -34
- package/dist/session/memory.d.ts +61 -18
- package/dist/session/memory.js +342 -79
- package/dist/session/session-manager.d.ts +35 -1
- package/dist/session/session-manager.js +197 -4
- package/dist/session/transcript.d.ts +5 -1
- package/dist/session/transcript.js +38 -5
- package/dist/settings/manager.d.ts +1 -0
- package/dist/settings/manager.js +87 -37
- package/dist/settings/schema-export.d.ts +2 -3
- package/dist/settings/schema-export.js +2 -3
- package/dist/settings/schema.d.ts +21 -0
- package/dist/settings/schema.js +12 -0
- package/dist/skills/scanner.d.ts +3 -2
- package/dist/skills/scanner.js +12 -9
- package/dist/tool-system/builtin/background-jobs.d.ts +10 -1
- package/dist/tool-system/builtin/background-jobs.js +12 -1
- package/dist/tool-system/builtin/background-work.d.ts +17 -18
- package/dist/tool-system/builtin/background-work.js +51 -5
- package/dist/tool-system/builtin/config.d.ts +2 -1
- package/dist/tool-system/builtin/config.js +16 -11
- package/dist/tool-system/builtin/drive-claude-code.d.ts +16 -2
- package/dist/tool-system/builtin/drive-claude-code.js +301 -47
- package/dist/tool-system/builtin/edit.js +5 -2
- package/dist/tool-system/builtin/generate-video.d.ts +1 -0
- package/dist/tool-system/builtin/generate-video.js +13 -4
- package/dist/tool-system/builtin/index.d.ts +8 -3
- package/dist/tool-system/builtin/index.js +37 -21
- package/dist/tool-system/builtin/lsp.d.ts +2 -1
- package/dist/tool-system/builtin/lsp.js +6 -3
- package/dist/tool-system/builtin/memory.js +40 -8
- package/dist/tool-system/builtin/notebook-edit.js +5 -2
- package/dist/tool-system/builtin/powershell.d.ts +5 -2
- package/dist/tool-system/builtin/powershell.js +11 -7
- package/dist/tool-system/builtin/read.js +118 -6
- package/dist/tool-system/builtin/view-image.d.ts +2 -2
- package/dist/tool-system/builtin/view-image.js +109 -19
- package/dist/tool-system/builtin/worktree.d.ts +4 -4
- package/dist/tool-system/builtin/worktree.js +297 -74
- package/dist/tool-system/builtin/write.js +5 -3
- package/dist/tool-system/context.d.ts +23 -1
- package/dist/tool-system/executor.d.ts +1 -5
- package/dist/tool-system/executor.js +94 -115
- package/dist/tool-system/mcp-manager.d.ts +18 -5
- package/dist/tool-system/mcp-manager.js +157 -74
- package/dist/tool-system/mcp-stdio-diagnostics.d.ts +9 -0
- package/dist/tool-system/mcp-stdio-diagnostics.js +93 -0
- package/dist/tool-system/path-policy.d.ts +2 -0
- package/dist/tool-system/path-policy.js +41 -12
- package/dist/tool-system/permission.d.ts +28 -7
- package/dist/tool-system/permission.js +130 -49
- package/dist/tool-system/registry.js +11 -4
- package/dist/tool-system/tool-result-redaction.d.ts +7 -0
- package/dist/tool-system/tool-result-redaction.js +48 -0
- package/dist/tool-system/workspace-bridge.d.ts +11 -0
- package/dist/tool-system/workspace-bridge.js +1 -0
- package/dist/types.d.ts +86 -5
- package/dist/utils/toolDisplay.js +1 -1
- package/package.json +4 -3
|
@@ -7,10 +7,12 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
7
7
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
8
8
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
9
9
|
import { logger } from "../logging/logger.js";
|
|
10
|
-
import {
|
|
10
|
+
import { getCredentialAccess } from "../credentials/access.js";
|
|
11
11
|
import { ENV_ALLOWLIST } from "../runtime/spawn-common.js";
|
|
12
|
-
import {
|
|
12
|
+
import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
+
import { diagnoseMcpStdioMissingCommand, previewPath } from "./mcp-stdio-diagnostics.js";
|
|
15
|
+
import { codeShellHome } from "../session/session-manager.js";
|
|
14
16
|
/**
|
|
15
17
|
* Read a required secret from `process.env` by NAME (Codex-style env-secret
|
|
16
18
|
* handling — the value is never persisted in MCP config). A referenced env var
|
|
@@ -88,6 +90,20 @@ export function buildHttpHeaders(serverName, config, resolveCredential) {
|
|
|
88
90
|
}
|
|
89
91
|
return headers;
|
|
90
92
|
}
|
|
93
|
+
export async function buildHttpHeadersWithCredentialAccess(serverName, config, access = getCredentialAccess()) {
|
|
94
|
+
if (!config.credentialRef)
|
|
95
|
+
return buildHttpHeaders(serverName, config);
|
|
96
|
+
if (!access.resolveValue) {
|
|
97
|
+
throw new Error(`MCP server "${serverName}": credential "${config.credentialRef}" not found or empty`);
|
|
98
|
+
}
|
|
99
|
+
const secret = await access.resolveValue({
|
|
100
|
+
cwd: undefined,
|
|
101
|
+
id: config.credentialRef,
|
|
102
|
+
scope: "full",
|
|
103
|
+
purpose: "mcp",
|
|
104
|
+
});
|
|
105
|
+
return buildHttpHeaders(serverName, config, (id) => id === config.credentialRef ? secret : undefined);
|
|
106
|
+
}
|
|
91
107
|
/**
|
|
92
108
|
* Infer the transport when the config doesn't name one: a url-only entry is
|
|
93
109
|
* HTTP, everything else stdio. This is the CC `.mcp.json` convention —
|
|
@@ -117,7 +133,29 @@ export function inferTransportType(config) {
|
|
|
117
133
|
* spill itself is bounded by the byte budget below; this cap is just
|
|
118
134
|
* to keep ls(~/.code-shell/mcp_images) tractable.
|
|
119
135
|
*/
|
|
136
|
+
const MAX_MCP_IMAGE_SPILLS_PER_TOOL = 50;
|
|
120
137
|
const MAX_MCP_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
138
|
+
function spillTimestamp(filename, prefix) {
|
|
139
|
+
const suffix = filename.slice(prefix.length);
|
|
140
|
+
const dot = suffix.lastIndexOf(".");
|
|
141
|
+
const raw = dot === -1 ? suffix : suffix.slice(0, dot);
|
|
142
|
+
const timestamp = Number(raw);
|
|
143
|
+
return Number.isFinite(timestamp) ? timestamp : 0;
|
|
144
|
+
}
|
|
145
|
+
async function pruneMcpImageSpills(baseDir, prefix) {
|
|
146
|
+
const entries = await readdir(baseDir, { withFileTypes: true });
|
|
147
|
+
const spills = entries
|
|
148
|
+
.filter((entry) => entry.isFile() && entry.name.startsWith(prefix))
|
|
149
|
+
.map((entry) => ({
|
|
150
|
+
name: entry.name,
|
|
151
|
+
timestamp: spillTimestamp(entry.name, prefix),
|
|
152
|
+
}))
|
|
153
|
+
.sort((a, b) => a.timestamp - b.timestamp || a.name.localeCompare(b.name));
|
|
154
|
+
const excess = spills.length - MAX_MCP_IMAGE_SPILLS_PER_TOOL;
|
|
155
|
+
if (excess <= 0)
|
|
156
|
+
return;
|
|
157
|
+
await Promise.all(spills.slice(0, excess).map((entry) => unlink(join(baseDir, entry.name)).catch(() => { })));
|
|
158
|
+
}
|
|
121
159
|
/**
|
|
122
160
|
* Persist an MCP-returned image to disk and return the textual
|
|
123
161
|
* reference the LLM should see. Images larger than
|
|
@@ -129,9 +167,7 @@ const MAX_MCP_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
|
129
167
|
* [mcp-image] server=playwright tool=screenshot saved=/abs/path.png (123 KB)
|
|
130
168
|
*/
|
|
131
169
|
export async function spillMcpImage(serverName, toolName, base64, mimeType, opts) {
|
|
132
|
-
const
|
|
133
|
-
const baseDir = opts?.baseDir ??
|
|
134
|
-
(home ? join(home, ".code-shell", "mcp_images") : join("/tmp", "code-shell-mcp-images"));
|
|
170
|
+
const baseDir = opts?.baseDir ?? join(codeShellHome(), "mcp_images");
|
|
135
171
|
const now = opts?.now ?? Date.now;
|
|
136
172
|
const decodedBytes = Math.floor((base64.length * 3) / 4);
|
|
137
173
|
if (decodedBytes > MAX_MCP_IMAGE_BYTES) {
|
|
@@ -146,11 +182,13 @@ export async function spillMcpImage(serverName, toolName, base64, mimeType, opts
|
|
|
146
182
|
: "png";
|
|
147
183
|
const safeServer = serverName.replace(/[^\w.-]+/g, "_");
|
|
148
184
|
const safeTool = toolName.replace(/[^\w.-]+/g, "_");
|
|
149
|
-
const
|
|
185
|
+
const prefix = `${safeServer}-${safeTool}-`;
|
|
186
|
+
const filename = `${prefix}${now()}.${ext}`;
|
|
150
187
|
const filePath = join(baseDir, filename);
|
|
151
188
|
try {
|
|
152
189
|
await mkdir(baseDir, { recursive: true });
|
|
153
190
|
await writeFile(filePath, Buffer.from(base64, "base64"));
|
|
191
|
+
await pruneMcpImageSpills(baseDir, prefix);
|
|
154
192
|
}
|
|
155
193
|
catch (err) {
|
|
156
194
|
logger.warn("mcp.image_spill_failed", {
|
|
@@ -204,7 +242,10 @@ export function buildRegisteredTool(serverName, tool) {
|
|
|
204
242
|
return {
|
|
205
243
|
name: toOpenAIToolName(`mcp_${serverName}_${tool.name}`),
|
|
206
244
|
description: `[${serverName}] ${tool.description ?? tool.name}`,
|
|
207
|
-
inputSchema: tool.inputSchema ?? {
|
|
245
|
+
inputSchema: tool.inputSchema ?? {
|
|
246
|
+
type: "object",
|
|
247
|
+
properties: {},
|
|
248
|
+
},
|
|
208
249
|
source: "mcp",
|
|
209
250
|
serverName,
|
|
210
251
|
permissionDefault: "ask",
|
|
@@ -246,13 +287,16 @@ export class MCPManager {
|
|
|
246
287
|
* Connect to all configured MCP servers and register their tools.
|
|
247
288
|
*/
|
|
248
289
|
async connectAll(servers, owner) {
|
|
290
|
+
const enabledNames = this.enabledServerNames(servers);
|
|
249
291
|
// Register this owner's desired set up front (see reconcile's shared-pool
|
|
250
292
|
// note) so a later reconcile from ANOTHER session can't disconnect servers
|
|
251
293
|
// this session connected at run start.
|
|
252
294
|
if (owner !== undefined) {
|
|
253
|
-
this.desiredByOwner.set(owner,
|
|
254
|
-
|
|
255
|
-
|
|
295
|
+
this.desiredByOwner.set(owner, enabledNames);
|
|
296
|
+
this.desiredServerNames = this.unionDesired() ?? new Set();
|
|
297
|
+
}
|
|
298
|
+
else if (this.desiredByOwner.size === 0) {
|
|
299
|
+
this.desiredServerNames = enabledNames;
|
|
256
300
|
}
|
|
257
301
|
// Codex-style toggle: skip servers explicitly disabled in settings.
|
|
258
302
|
// Only the literal `false` disables — absent / true / any other value
|
|
@@ -278,26 +322,42 @@ export class MCPManager {
|
|
|
278
322
|
}
|
|
279
323
|
}
|
|
280
324
|
async reconcile(servers, owner) {
|
|
281
|
-
const enabledNames =
|
|
282
|
-
.filter(([, config]) => config.enabled !== false)
|
|
283
|
-
.map(([name]) => name));
|
|
284
|
-
this.desiredServerNames = enabledNames;
|
|
325
|
+
const enabledNames = this.enabledServerNames(servers);
|
|
285
326
|
// Shared-pool semantics: this ONE pool serves every session in the worker,
|
|
286
327
|
// and sessions in different projects legitimately want DIFFERENT server
|
|
287
328
|
// sets (per-project capabilityOverrides). Disconnect only servers that NO
|
|
288
329
|
// registered owner wants — otherwise the per-session hot-reload patches
|
|
289
330
|
// would thrash each other's connections (last reconcile wins, killing a
|
|
290
|
-
// server another project's session is using). Owners are engines;
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
// correctness regardless.
|
|
331
|
+
// server another project's session is using). Owners are engines; closed
|
|
332
|
+
// sessions call unregisterOwner() so their desired sets stop retaining idle
|
|
333
|
+
// connections.
|
|
294
334
|
if (owner !== undefined)
|
|
295
335
|
this.desiredByOwner.set(owner, enabledNames);
|
|
296
336
|
const union = this.unionDesired() ?? enabledNames;
|
|
337
|
+
this.desiredServerNames = union;
|
|
297
338
|
const stale = this.listServers().filter((name) => !union.has(name));
|
|
298
339
|
await Promise.all(stale.map((name) => this.disconnect(name)));
|
|
299
340
|
await this.connectAll(servers, owner);
|
|
300
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* Remove one owner from the shared desired-server pool and disconnect servers
|
|
344
|
+
* that no remaining owner wants. This is called when a session-owned Engine
|
|
345
|
+
* is closed; without it project-scoped MCP servers can stay connected until
|
|
346
|
+
* worker shutdown.
|
|
347
|
+
*/
|
|
348
|
+
async unregisterOwner(owner) {
|
|
349
|
+
if (!this.desiredByOwner.delete(owner))
|
|
350
|
+
return;
|
|
351
|
+
const desired = this.unionDesired() ?? new Set();
|
|
352
|
+
this.desiredServerNames = desired;
|
|
353
|
+
const stale = this.listServers().filter((name) => !desired.has(name));
|
|
354
|
+
await Promise.all(stale.map((name) => this.disconnect(name)));
|
|
355
|
+
}
|
|
356
|
+
enabledServerNames(servers) {
|
|
357
|
+
return new Set(Object.entries(servers)
|
|
358
|
+
.filter(([, config]) => config.enabled !== false)
|
|
359
|
+
.map(([name]) => name));
|
|
360
|
+
}
|
|
301
361
|
/** Union of every registered owner's desired set; null when none registered. */
|
|
302
362
|
unionDesired() {
|
|
303
363
|
if (this.desiredByOwner.size === 0)
|
|
@@ -360,12 +420,10 @@ export class MCPManager {
|
|
|
360
420
|
if (!config.url) {
|
|
361
421
|
throw new Error(`MCP server "${name}": url is required for ${transportType} transport`);
|
|
362
422
|
}
|
|
363
|
-
// credentialRef resolves against
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
const credStore = new CredentialStore(undefined);
|
|
368
|
-
const headers = buildHttpHeaders(name, config, (id) => credStore.resolve(id)?.secret);
|
|
423
|
+
// credentialRef resolves against user-scope credential access. The shared
|
|
424
|
+
// MCPManager pool has no per-session cwd, and MCP-referenced credentials
|
|
425
|
+
// (e.g. a Figma token) remain user-global by design.
|
|
426
|
+
const headers = await buildHttpHeadersWithCredentialAccess(name, config);
|
|
369
427
|
transport = new StreamableHTTPClientTransport(new URL(config.url), {
|
|
370
428
|
requestInit: Object.keys(headers).length ? { headers } : undefined,
|
|
371
429
|
});
|
|
@@ -393,6 +451,22 @@ export class MCPManager {
|
|
|
393
451
|
catch {
|
|
394
452
|
// ignore cleanup errors
|
|
395
453
|
}
|
|
454
|
+
if (transportType === "stdio" && config.command) {
|
|
455
|
+
const diagnostic = await diagnoseMcpStdioMissingCommand(config.command, err);
|
|
456
|
+
if (diagnostic) {
|
|
457
|
+
logger.warn("mcp.stdio_command_missing", {
|
|
458
|
+
server: name,
|
|
459
|
+
command: config.command,
|
|
460
|
+
foundPaths: diagnostic.foundPaths,
|
|
461
|
+
path: previewPath(),
|
|
462
|
+
message: diagnostic.message,
|
|
463
|
+
});
|
|
464
|
+
const original = err instanceof Error ? err.message : String(err);
|
|
465
|
+
const enhanced = new Error(`${original}\n${diagnostic.message}`);
|
|
466
|
+
enhanced.cause = err;
|
|
467
|
+
throw enhanced;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
396
470
|
throw err;
|
|
397
471
|
}
|
|
398
472
|
finally {
|
|
@@ -412,59 +486,65 @@ export class MCPManager {
|
|
|
412
486
|
* Discover tools from an MCP server and register them.
|
|
413
487
|
*/
|
|
414
488
|
async discoverTools(serverName, client) {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
489
|
+
try {
|
|
490
|
+
const result = await client.listTools();
|
|
491
|
+
for (const tool of result.tools) {
|
|
492
|
+
const registered = buildRegisteredTool(serverName, tool);
|
|
493
|
+
// Register with an executor that calls the MCP server
|
|
494
|
+
this.toolRegistry.registerTool(registered, async (args, ctx) => {
|
|
495
|
+
const callResult = await client.callTool({
|
|
496
|
+
name: tool.name,
|
|
497
|
+
arguments: stripInternalToolArgs(args),
|
|
498
|
+
}, undefined, ctx?.signal ? { signal: ctx.signal } : undefined);
|
|
499
|
+
// Extract text + image content from the result. Image blobs
|
|
500
|
+
// are spilled to ~/.code-shell/mcp_images/ so they don't bloat
|
|
501
|
+
// the LLM message tree — same pattern as the GenerateImage
|
|
502
|
+
// tool — and the model sees a textual reference it can Read
|
|
503
|
+
// on a later turn if it needs the pixels. This sidesteps the
|
|
504
|
+
// "MCP returned a 5MB screenshot → token budget exploded"
|
|
505
|
+
// failure mode that bit Codex (issue #11845); we never put
|
|
506
|
+
// raw base64 image data in the result text. See
|
|
507
|
+
// TODO-week.md #9c + docs/research-cc-vs-codex-image-handling.md §B.
|
|
508
|
+
const parts = [];
|
|
509
|
+
if (Array.isArray(callResult.content)) {
|
|
510
|
+
for (const item of callResult.content) {
|
|
511
|
+
if (typeof item === "string") {
|
|
512
|
+
parts.push(item);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (typeof item !== "object" || item === null)
|
|
516
|
+
continue;
|
|
517
|
+
if ("text" in item) {
|
|
518
|
+
parts.push(String(item.text));
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (item.type === "image") {
|
|
522
|
+
const block = item;
|
|
523
|
+
if (typeof block.data === "string" && block.data.length > 0) {
|
|
524
|
+
const note = await spillMcpImage(serverName, tool.name, block.data, block.mimeType ?? "image/png");
|
|
525
|
+
parts.push(note);
|
|
526
|
+
}
|
|
451
527
|
}
|
|
452
528
|
}
|
|
453
529
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
530
|
+
const body = parts.join("\n") || "(no output)";
|
|
531
|
+
// Trust boundary: MCP output is external content. Wrap it so the
|
|
532
|
+
// model sees an explicit reminder that the body comes from an
|
|
533
|
+
// untrusted server and any instructions inside are content, not
|
|
534
|
+
// commands. The marker is intentionally short so it doesn't bloat
|
|
535
|
+
// every tool result, but distinct enough that prompt-injected
|
|
536
|
+
// strings can't fake their way out.
|
|
537
|
+
return wrapMcpOutput(serverName, tool.name, body);
|
|
538
|
+
});
|
|
539
|
+
const set = this.registeredToolsByServer.get(serverName) ?? new Set();
|
|
540
|
+
set.add(registered.name);
|
|
541
|
+
this.registeredToolsByServer.set(serverName, set);
|
|
542
|
+
logger.info("mcp.tool_registered", { server: serverName, tool: registered.name });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
catch (err) {
|
|
546
|
+
await this.disconnect(serverName);
|
|
547
|
+
throw err;
|
|
468
548
|
}
|
|
469
549
|
}
|
|
470
550
|
/**
|
|
@@ -472,6 +552,8 @@ export class MCPManager {
|
|
|
472
552
|
*/
|
|
473
553
|
async disconnectAll() {
|
|
474
554
|
await Promise.all([...this.connections.keys()].map((name) => this.disconnect(name)));
|
|
555
|
+
this.desiredByOwner.clear();
|
|
556
|
+
this.desiredServerNames = null;
|
|
475
557
|
}
|
|
476
558
|
async disconnect(name) {
|
|
477
559
|
const conn = this.connections.get(name);
|
|
@@ -545,6 +627,7 @@ export class MCPManager {
|
|
|
545
627
|
uri: r.uri,
|
|
546
628
|
name: r.name ?? r.uri,
|
|
547
629
|
description: r.description,
|
|
630
|
+
serverName: name,
|
|
548
631
|
});
|
|
549
632
|
}
|
|
550
633
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function isBareCommand(command: string): boolean;
|
|
2
|
+
export declare function isMissingCommandError(error: unknown): boolean;
|
|
3
|
+
export declare function probeCommonExecutableLocations(command: string, env?: NodeJS.ProcessEnv): Promise<string[]>;
|
|
4
|
+
export declare function classifyMcpStdioMissingCommand(command: string, foundPaths: readonly string[]): string;
|
|
5
|
+
export declare function diagnoseMcpStdioMissingCommand(command: string, error: unknown, env?: NodeJS.ProcessEnv): Promise<{
|
|
6
|
+
message: string;
|
|
7
|
+
foundPaths: string[];
|
|
8
|
+
} | null>;
|
|
9
|
+
export declare function previewPath(env?: NodeJS.ProcessEnv): string;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access } from "node:fs/promises";
|
|
3
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
4
|
+
const COMMON_POSIX_BIN_DIRS = [
|
|
5
|
+
"/opt/homebrew/bin",
|
|
6
|
+
"/usr/local/bin",
|
|
7
|
+
"/home/linuxbrew/.linuxbrew/bin",
|
|
8
|
+
"/usr/bin",
|
|
9
|
+
"/bin",
|
|
10
|
+
];
|
|
11
|
+
function unique(entries) {
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
const trimmed = entry.trim();
|
|
16
|
+
if (!trimmed || seen.has(trimmed))
|
|
17
|
+
continue;
|
|
18
|
+
seen.add(trimmed);
|
|
19
|
+
out.push(trimmed);
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
function commonExecutableDirs(env = process.env) {
|
|
24
|
+
const home = env.HOME?.trim();
|
|
25
|
+
return unique([
|
|
26
|
+
...COMMON_POSIX_BIN_DIRS,
|
|
27
|
+
...(home
|
|
28
|
+
? [join(home, ".bun", "bin"), join(home, ".local", "bin"), join(home, ".npm-global", "bin")]
|
|
29
|
+
: []),
|
|
30
|
+
]);
|
|
31
|
+
}
|
|
32
|
+
export function isBareCommand(command) {
|
|
33
|
+
const trimmed = command.trim();
|
|
34
|
+
return !!trimmed && !trimmed.includes("/") && !trimmed.includes("\\") && !isAbsolute(trimmed);
|
|
35
|
+
}
|
|
36
|
+
export function isMissingCommandError(error) {
|
|
37
|
+
const err = error;
|
|
38
|
+
const message = typeof err.message === "string" ? err.message : String(error);
|
|
39
|
+
return err.code === "ENOENT" || /\bENOENT\b/i.test(message) || /command not found/i.test(message);
|
|
40
|
+
}
|
|
41
|
+
async function isExecutable(filePath) {
|
|
42
|
+
try {
|
|
43
|
+
await access(filePath, constants.X_OK);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function probeCommonExecutableLocations(command, env = process.env) {
|
|
51
|
+
if (!isBareCommand(command) || process.platform === "win32")
|
|
52
|
+
return [];
|
|
53
|
+
const found = [];
|
|
54
|
+
for (const dir of commonExecutableDirs(env)) {
|
|
55
|
+
const candidate = join(dir, command);
|
|
56
|
+
if (await isExecutable(candidate))
|
|
57
|
+
found.push(candidate);
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
function installGuidance(command) {
|
|
62
|
+
if (command === "node" || command === "npx")
|
|
63
|
+
return "Please install Node.js and restart CodeShell.";
|
|
64
|
+
if (command === "bun" || command === "bunx")
|
|
65
|
+
return "Please install Bun and restart CodeShell.";
|
|
66
|
+
return `Please install "${command}" and restart CodeShell.`;
|
|
67
|
+
}
|
|
68
|
+
export function classifyMcpStdioMissingCommand(command, foundPaths) {
|
|
69
|
+
const trimmed = command.trim();
|
|
70
|
+
if (foundPaths.length > 0) {
|
|
71
|
+
return [
|
|
72
|
+
`MCP stdio command "${trimmed}" failed to start: detected ${trimmed} at ${foundPaths[0]},`,
|
|
73
|
+
"but that directory was not available on PATH.",
|
|
74
|
+
"Login-shell PATH injection may have failed; restart CodeShell or configure this MCP command as an absolute path.",
|
|
75
|
+
].join(" ");
|
|
76
|
+
}
|
|
77
|
+
return [
|
|
78
|
+
`MCP stdio command "${trimmed}" failed to start: ${trimmed} was not found on PATH or in common install locations.`,
|
|
79
|
+
installGuidance(trimmed),
|
|
80
|
+
].join(" ");
|
|
81
|
+
}
|
|
82
|
+
export async function diagnoseMcpStdioMissingCommand(command, error, env = process.env) {
|
|
83
|
+
if (!isBareCommand(command) || !isMissingCommandError(error))
|
|
84
|
+
return null;
|
|
85
|
+
const foundPaths = await probeCommonExecutableLocations(command, env);
|
|
86
|
+
return {
|
|
87
|
+
message: classifyMcpStdioMissingCommand(command, foundPaths),
|
|
88
|
+
foundPaths,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export function previewPath(env = process.env) {
|
|
92
|
+
return (env.PATH ?? "").split(delimiter).filter(Boolean).join(delimiter);
|
|
93
|
+
}
|
|
@@ -52,6 +52,8 @@ export interface ClassifyOptions {
|
|
|
52
52
|
export type PathApprovalScope = "once" | "session" | "project";
|
|
53
53
|
/** Test seam: clear the in-memory session grants. */
|
|
54
54
|
export declare function _resetSessionPathGrants(): void;
|
|
55
|
+
export declare function openSessionPathApprovals(sessionId: string): void;
|
|
56
|
+
export declare function clearSessionPathApprovals(sessionId: string): void;
|
|
55
57
|
/**
|
|
56
58
|
* Classify a file path against the workspace + sensitive-path policy.
|
|
57
59
|
*
|
|
@@ -105,6 +105,10 @@ function expandTilde(p) {
|
|
|
105
105
|
}
|
|
106
106
|
/** sessionId → set of approved directory grants (prefix + operation). */
|
|
107
107
|
const sessionPathGrants = new Map();
|
|
108
|
+
/** Session ids explicitly closed by ChatSessionManager; late approvals must not recreate grants. */
|
|
109
|
+
const closedPathApprovalSessions = new Set();
|
|
110
|
+
/** Per-session prompt chains for enforcePathPolicyWithApproval (see comment there). */
|
|
111
|
+
const askChains = new Map();
|
|
108
112
|
/** True if a grant for `grantOp` authorizes an operation of `wantOp`. */
|
|
109
113
|
function grantCoversOp(grantOp, wantOp) {
|
|
110
114
|
// write ⊇ {read, write}; read ⊇ {read}.
|
|
@@ -198,6 +202,8 @@ function storedOp(g) {
|
|
|
198
202
|
function recordPathApproval(scope, resolved, operation, cwd, sessionId) {
|
|
199
203
|
if (scope === "once")
|
|
200
204
|
return;
|
|
205
|
+
if (sessionId && closedPathApprovalSessions.has(sessionId))
|
|
206
|
+
return;
|
|
201
207
|
const prefix = dirPrefix(dirname(resolved));
|
|
202
208
|
if (scope === "session") {
|
|
203
209
|
if (!sessionId)
|
|
@@ -271,6 +277,16 @@ function recordPathApproval(scope, resolved, operation, cwd, sessionId) {
|
|
|
271
277
|
/** Test seam: clear the in-memory session grants. */
|
|
272
278
|
export function _resetSessionPathGrants() {
|
|
273
279
|
sessionPathGrants.clear();
|
|
280
|
+
closedPathApprovalSessions.clear();
|
|
281
|
+
askChains.clear();
|
|
282
|
+
}
|
|
283
|
+
export function openSessionPathApprovals(sessionId) {
|
|
284
|
+
closedPathApprovalSessions.delete(sessionId);
|
|
285
|
+
}
|
|
286
|
+
export function clearSessionPathApprovals(sessionId) {
|
|
287
|
+
sessionPathGrants.delete(sessionId);
|
|
288
|
+
closedPathApprovalSessions.add(sessionId);
|
|
289
|
+
askChains.delete(sessionId);
|
|
274
290
|
}
|
|
275
291
|
/**
|
|
276
292
|
* Best-effort resolution. realpath fails when the path doesn't exist yet —
|
|
@@ -356,6 +372,12 @@ function isSafeCodeShellDiagnosticRead(resolved) {
|
|
|
356
372
|
}
|
|
357
373
|
return false;
|
|
358
374
|
}
|
|
375
|
+
function isNoRepoAttachmentRead(resolved, operation) {
|
|
376
|
+
if (operation !== "read")
|
|
377
|
+
return false;
|
|
378
|
+
const root = join(homedir(), ".code-shell", "no-repo", ".code-shell", "attachments");
|
|
379
|
+
return isInsideDir(resolved, root);
|
|
380
|
+
}
|
|
359
381
|
/**
|
|
360
382
|
* Classify a file path against the workspace + sensitive-path policy.
|
|
361
383
|
*
|
|
@@ -396,6 +418,13 @@ export function classifyPath(rawPath, opts) {
|
|
|
396
418
|
// Sensitive: write is always denied, read always asks. Workspace placement
|
|
397
419
|
// doesn't soften the rule — an `.env` in the project still asks on read.
|
|
398
420
|
if (sensitiveLabel) {
|
|
421
|
+
if (isNoRepoAttachmentRead(resolved, opts.operation)) {
|
|
422
|
+
return {
|
|
423
|
+
decision: "allow",
|
|
424
|
+
reason: "no-repo attachment read",
|
|
425
|
+
resolvedPath: resolved,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
399
428
|
if (opts.operation === "read" && isSafeCodeShellDiagnosticRead(resolved)) {
|
|
400
429
|
return {
|
|
401
430
|
decision: "allow",
|
|
@@ -461,8 +490,8 @@ export function enforcePathPolicy(filePath, operation, workspaceRoot) {
|
|
|
461
490
|
}
|
|
462
491
|
// ask — MVP refuses with explanatory message until askUser plumbing
|
|
463
492
|
// lands. The conservative bias matches the plan's leaning answer for Q1.
|
|
464
|
-
return `Error: path requires approval — ${c.reason}. Path: ${c.resolvedPath}. ` +
|
|
465
|
-
`Set CODESHELL_PATH_POLICY=off to disable enforcement during a rollback
|
|
493
|
+
return (`Error: path requires approval — ${c.reason}. Path: ${c.resolvedPath}. ` +
|
|
494
|
+
`Set CODESHELL_PATH_POLICY=off to disable enforcement during a rollback.`);
|
|
466
495
|
}
|
|
467
496
|
export async function enforcePathPolicyWithApproval(filePath, operation, ctx) {
|
|
468
497
|
if (ctx?.cwd === undefined)
|
|
@@ -481,8 +510,8 @@ export async function enforcePathPolicyWithApproval(filePath, operation, ctx) {
|
|
|
481
510
|
return `Error: blocked by path policy — ${c.reason}. Path: ${c.resolvedPath}`;
|
|
482
511
|
}
|
|
483
512
|
if (operation === "write" && ctx.planMode) {
|
|
484
|
-
return `Error: blocked by path policy — ${c.reason}. Path: ${c.resolvedPath}. ` +
|
|
485
|
-
`Plan mode does not allow file writes
|
|
513
|
+
return (`Error: blocked by path policy — ${c.reason}. Path: ${c.resolvedPath}. ` +
|
|
514
|
+
`Plan mode does not allow file writes.`);
|
|
486
515
|
}
|
|
487
516
|
// Already approved this directory for this OPERATION (this session or
|
|
488
517
|
// persisted for the project)? Proceed without re-prompting — this is the fix
|
|
@@ -491,8 +520,8 @@ export async function enforcePathPolicyWithApproval(filePath, operation, ctx) {
|
|
|
491
520
|
if (isPathPreApproved(c.resolvedPath, operation, ctx.cwd, ctx.sessionId))
|
|
492
521
|
return null;
|
|
493
522
|
if (!ctx.askUser) {
|
|
494
|
-
return `Error: path requires approval — ${c.reason}. Path: ${c.resolvedPath}. ` +
|
|
495
|
-
`No interactive approval UI is available in this run
|
|
523
|
+
return (`Error: path requires approval — ${c.reason}. Path: ${c.resolvedPath}. ` +
|
|
524
|
+
`No interactive approval UI is available in this run.`);
|
|
496
525
|
}
|
|
497
526
|
// Serialize concurrent asks (per session) and RE-CHECK grants when our turn
|
|
498
527
|
// comes. Parallel tools hitting the same not-yet-approved directory all pass
|
|
@@ -502,7 +531,8 @@ export async function enforcePathPolicyWithApproval(filePath, operation, ctx) {
|
|
|
502
531
|
const chainKey = ctx.sessionId ?? "__global__";
|
|
503
532
|
const prevTurn = askChains.get(chainKey) ?? Promise.resolve();
|
|
504
533
|
let release;
|
|
505
|
-
|
|
534
|
+
const currentTurn = new Promise((r) => (release = r));
|
|
535
|
+
askChains.set(chainKey, currentTurn);
|
|
506
536
|
try {
|
|
507
537
|
await prevTurn;
|
|
508
538
|
if (isPathPreApproved(c.resolvedPath, operation, ctx.cwd, ctx.sessionId))
|
|
@@ -511,6 +541,9 @@ export async function enforcePathPolicyWithApproval(filePath, operation, ctx) {
|
|
|
511
541
|
}
|
|
512
542
|
finally {
|
|
513
543
|
release();
|
|
544
|
+
if (askChains.get(chainKey) === currentTurn) {
|
|
545
|
+
askChains.delete(chainKey);
|
|
546
|
+
}
|
|
514
547
|
}
|
|
515
548
|
}
|
|
516
549
|
/** The actual interactive ask — split out so the serialized section reads flat.
|
|
@@ -521,9 +554,7 @@ async function promptForPathApproval(c, operation, ctx) {
|
|
|
521
554
|
// "工作区外路径" header was misleading for sensitive-path asks.
|
|
522
555
|
const isSensitive = c.reason.startsWith("sensitive");
|
|
523
556
|
const what = operation === "read" ? "读取" : "写入";
|
|
524
|
-
const title = isSensitive
|
|
525
|
-
? `工具想${what}敏感文件`
|
|
526
|
-
: `工具想${what}工作区外路径`;
|
|
557
|
+
const title = isSensitive ? `工具想${what}敏感文件` : `工具想${what}工作区外路径`;
|
|
527
558
|
const header = isSensitive ? "敏感文件权限" : "路径权限";
|
|
528
559
|
// Scope options carry remembered grants for the directory of this path, so
|
|
529
560
|
// the same folder isn't re-prompted. Labels are matched by exact string
|
|
@@ -566,8 +597,6 @@ async function promptForPathApproval(c, operation, ctx) {
|
|
|
566
597
|
}
|
|
567
598
|
return `Error: path approval denied by user — ${c.reason}. Path: ${c.resolvedPath}`;
|
|
568
599
|
}
|
|
569
|
-
/** Per-session prompt chains for enforcePathPolicyWithApproval (see comment there). */
|
|
570
|
-
const askChains = new Map();
|
|
571
600
|
/**
|
|
572
601
|
* Internal: reset the "disabled warning" latch. Tests flip the env var
|
|
573
602
|
* between cases and need each one to be able to re-trigger the warning.
|