@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.
Files changed (177) hide show
  1. package/THIRD_PARTY_NOTICES.md +206 -0
  2. package/dist/automation/scheduler.d.ts +13 -7
  3. package/dist/automation/scheduler.js +116 -37
  4. package/dist/capability-control/service.d.ts +2 -0
  5. package/dist/capability-control/service.js +4 -2
  6. package/dist/cc-orchestrator/agent-adapter.d.ts +2 -0
  7. package/dist/cc-orchestrator/agent-adapter.js +7 -1
  8. package/dist/cc-orchestrator/codex-session-history.js +1 -1
  9. package/dist/cc-orchestrator/cwd-normalize.d.ts +2 -0
  10. package/dist/cc-orchestrator/cwd-normalize.js +19 -0
  11. package/dist/cc-orchestrator/external-agent-bindings.d.ts +27 -0
  12. package/dist/cc-orchestrator/external-agent-bindings.js +150 -0
  13. package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -0
  14. package/dist/cc-orchestrator/external-agent-driver.js +102 -51
  15. package/dist/cc-orchestrator/external-agent-session-store.d.ts +23 -0
  16. package/dist/cc-orchestrator/external-agent-session-store.js +146 -0
  17. package/dist/cc-orchestrator/session-history.js +2 -2
  18. package/dist/cli/agent-server-stdio.js +9 -2
  19. package/dist/context/compaction.d.ts +38 -1
  20. package/dist/context/compaction.js +138 -0
  21. package/dist/context/manager.d.ts +34 -2
  22. package/dist/context/manager.js +254 -58
  23. package/dist/context/token-counter.js +13 -0
  24. package/dist/credentials/access.d.ts +56 -0
  25. package/dist/credentials/access.js +183 -0
  26. package/dist/credentials/index.d.ts +1 -0
  27. package/dist/credentials/index.js +1 -0
  28. package/dist/credentials/inject-credential-tool.d.ts +3 -1
  29. package/dist/credentials/inject-credential-tool.js +30 -11
  30. package/dist/credentials/types.d.ts +2 -2
  31. package/dist/credentials/use-credential-tool.d.ts +9 -1
  32. package/dist/credentials/use-credential-tool.js +58 -45
  33. package/dist/engine/engine.d.ts +38 -12
  34. package/dist/engine/engine.js +477 -163
  35. package/dist/engine/image-policy.d.ts +6 -0
  36. package/dist/engine/image-policy.js +17 -6
  37. package/dist/engine/input-attachments.d.ts +13 -0
  38. package/dist/engine/input-attachments.js +255 -0
  39. package/dist/engine/model-facade.d.ts +5 -2
  40. package/dist/engine/model-facade.js +6 -16
  41. package/dist/engine/parse-task.d.ts +10 -0
  42. package/dist/engine/parse-task.js +5 -0
  43. package/dist/engine/query.js +2 -0
  44. package/dist/engine/session-usage.d.ts +12 -0
  45. package/dist/engine/session-usage.js +56 -0
  46. package/dist/engine/steer-queue.d.ts +2 -1
  47. package/dist/engine/steer-queue.js +2 -2
  48. package/dist/engine/streaming-tool-queue.d.ts +11 -7
  49. package/dist/engine/streaming-tool-queue.js +11 -7
  50. package/dist/engine/turn-loop.d.ts +34 -2
  51. package/dist/engine/turn-loop.js +269 -52
  52. package/dist/engine/types.d.ts +8 -0
  53. package/dist/git/worktree/crud.d.ts +69 -0
  54. package/dist/git/worktree/crud.js +206 -0
  55. package/dist/git/worktree/diff.d.ts +14 -0
  56. package/dist/git/worktree/diff.js +82 -0
  57. package/dist/git/worktree/git-exec.d.ts +7 -0
  58. package/dist/git/worktree/git-exec.js +51 -0
  59. package/dist/git/worktree/index.d.ts +5 -0
  60. package/dist/git/worktree/index.js +5 -0
  61. package/dist/git/worktree/query.d.ts +42 -0
  62. package/dist/git/worktree/query.js +121 -0
  63. package/dist/git/worktree/slug.d.ts +11 -0
  64. package/dist/git/worktree/slug.js +58 -0
  65. package/dist/git/worktree.d.ts +1 -84
  66. package/dist/git/worktree.js +5 -230
  67. package/dist/index.d.ts +31 -28
  68. package/dist/index.js +27 -25
  69. package/dist/logging/logger.js +6 -6
  70. package/dist/logging/sanitize-messages.d.ts +10 -2
  71. package/dist/logging/sanitize-messages.js +21 -6
  72. package/dist/plugins/installer/checkUpdate.d.ts +4 -1
  73. package/dist/plugins/installer/checkUpdate.js +4 -2
  74. package/dist/plugins/installer/install.js +2 -0
  75. package/dist/plugins/installer/installFromSource.js +9 -1
  76. package/dist/plugins/installer/parseSource.d.ts +4 -1
  77. package/dist/plugins/installer/parseSource.js +28 -10
  78. package/dist/plugins/installer/sourcePath.d.ts +9 -0
  79. package/dist/plugins/installer/sourcePath.js +50 -0
  80. package/dist/plugins/installer/update.d.ts +4 -1
  81. package/dist/plugins/installer/update.js +5 -3
  82. package/dist/plugins/parseMarketplaceInput.d.ts +4 -1
  83. package/dist/plugins/parseMarketplaceInput.js +4 -3
  84. package/dist/plugins/pluginInstaller.js +24 -22
  85. package/dist/preset/index.d.ts +1 -0
  86. package/dist/preset/index.js +16 -9
  87. package/dist/prompt/sections/base.md +1 -1
  88. package/dist/protocol/chat-session-manager.d.ts +2 -0
  89. package/dist/protocol/chat-session-manager.js +38 -2
  90. package/dist/protocol/chat-session.d.ts +5 -0
  91. package/dist/protocol/chat-session.js +2 -0
  92. package/dist/protocol/client.d.ts +13 -5
  93. package/dist/protocol/client.js +26 -3
  94. package/dist/protocol/server.d.ts +43 -12
  95. package/dist/protocol/server.js +326 -104
  96. package/dist/protocol/types.d.ts +51 -0
  97. package/dist/protocol/types.js +6 -0
  98. package/dist/run/FileRunStore.js +10 -1
  99. package/dist/run/Heartbeat.js +12 -0
  100. package/dist/run/RunApprovalBackend.d.ts +3 -0
  101. package/dist/run/RunApprovalBackend.js +41 -6
  102. package/dist/run/RunLock.js +2 -0
  103. package/dist/run/RunManager.d.ts +2 -0
  104. package/dist/run/RunManager.js +64 -24
  105. package/dist/run/ids.d.ts +2 -0
  106. package/dist/run/ids.js +23 -0
  107. package/dist/runtime/background-shell.d.ts +1 -0
  108. package/dist/runtime/background-shell.js +23 -13
  109. package/dist/runtime/safe-spawn.js +74 -11
  110. package/dist/runtime/spawn-common.js +10 -0
  111. package/dist/services/auto-dream.d.ts +15 -4
  112. package/dist/services/auto-dream.js +20 -20
  113. package/dist/services/dream-consolidation.d.ts +2 -3
  114. package/dist/services/dream-consolidation.js +65 -15
  115. package/dist/services/extract-memories.d.ts +14 -5
  116. package/dist/services/extract-memories.js +20 -3
  117. package/dist/services/global-dream-promotion.d.ts +23 -0
  118. package/dist/services/global-dream-promotion.js +112 -0
  119. package/dist/services/memory-orchestrator.js +347 -34
  120. package/dist/session/memory.d.ts +61 -18
  121. package/dist/session/memory.js +342 -79
  122. package/dist/session/session-manager.d.ts +35 -1
  123. package/dist/session/session-manager.js +197 -4
  124. package/dist/session/transcript.d.ts +5 -1
  125. package/dist/session/transcript.js +38 -5
  126. package/dist/settings/manager.d.ts +1 -0
  127. package/dist/settings/manager.js +87 -37
  128. package/dist/settings/schema-export.d.ts +2 -3
  129. package/dist/settings/schema-export.js +2 -3
  130. package/dist/settings/schema.d.ts +21 -0
  131. package/dist/settings/schema.js +12 -0
  132. package/dist/skills/scanner.d.ts +3 -2
  133. package/dist/skills/scanner.js +12 -9
  134. package/dist/tool-system/builtin/background-jobs.d.ts +10 -1
  135. package/dist/tool-system/builtin/background-jobs.js +12 -1
  136. package/dist/tool-system/builtin/background-work.d.ts +17 -18
  137. package/dist/tool-system/builtin/background-work.js +51 -5
  138. package/dist/tool-system/builtin/config.d.ts +2 -1
  139. package/dist/tool-system/builtin/config.js +16 -11
  140. package/dist/tool-system/builtin/drive-claude-code.d.ts +16 -2
  141. package/dist/tool-system/builtin/drive-claude-code.js +301 -47
  142. package/dist/tool-system/builtin/edit.js +5 -2
  143. package/dist/tool-system/builtin/generate-video.d.ts +1 -0
  144. package/dist/tool-system/builtin/generate-video.js +13 -4
  145. package/dist/tool-system/builtin/index.d.ts +8 -3
  146. package/dist/tool-system/builtin/index.js +37 -21
  147. package/dist/tool-system/builtin/lsp.d.ts +2 -1
  148. package/dist/tool-system/builtin/lsp.js +6 -3
  149. package/dist/tool-system/builtin/memory.js +40 -8
  150. package/dist/tool-system/builtin/notebook-edit.js +5 -2
  151. package/dist/tool-system/builtin/powershell.d.ts +5 -2
  152. package/dist/tool-system/builtin/powershell.js +11 -7
  153. package/dist/tool-system/builtin/read.js +118 -6
  154. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  155. package/dist/tool-system/builtin/view-image.js +109 -19
  156. package/dist/tool-system/builtin/worktree.d.ts +4 -4
  157. package/dist/tool-system/builtin/worktree.js +297 -74
  158. package/dist/tool-system/builtin/write.js +5 -3
  159. package/dist/tool-system/context.d.ts +23 -1
  160. package/dist/tool-system/executor.d.ts +1 -5
  161. package/dist/tool-system/executor.js +94 -115
  162. package/dist/tool-system/mcp-manager.d.ts +18 -5
  163. package/dist/tool-system/mcp-manager.js +157 -74
  164. package/dist/tool-system/mcp-stdio-diagnostics.d.ts +9 -0
  165. package/dist/tool-system/mcp-stdio-diagnostics.js +93 -0
  166. package/dist/tool-system/path-policy.d.ts +2 -0
  167. package/dist/tool-system/path-policy.js +41 -12
  168. package/dist/tool-system/permission.d.ts +28 -7
  169. package/dist/tool-system/permission.js +130 -49
  170. package/dist/tool-system/registry.js +11 -4
  171. package/dist/tool-system/tool-result-redaction.d.ts +7 -0
  172. package/dist/tool-system/tool-result-redaction.js +48 -0
  173. package/dist/tool-system/workspace-bridge.d.ts +11 -0
  174. package/dist/tool-system/workspace-bridge.js +1 -0
  175. package/dist/types.d.ts +86 -5
  176. package/dist/utils/toolDisplay.js +1 -1
  177. 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 { CredentialStore } from "../credentials/index.js";
10
+ import { getCredentialAccess } from "../credentials/access.js";
11
11
  import { ENV_ALLOWLIST } from "../runtime/spawn-common.js";
12
- import { writeFile, mkdir } from "node:fs/promises";
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 home = process.env.CODE_SHELL_HOME ?? process.env.HOME ?? "";
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 filename = `${safeServer}-${safeTool}-${now()}.${ext}`;
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 ?? { type: "object", properties: {} },
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, new Set(Object.entries(servers)
254
- .filter(([, c]) => c.enabled !== false)
255
- .map(([n]) => n)));
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 = new Set(Object.entries(servers)
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; a
291
- // closed session's desires linger (no engine-teardown hook), which merely
292
- // over-RETAINS idle connections — per-session tool visibility keeps
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 the user-scope CredentialStore. The
364
- // shared MCPManager pool has no per-session cwd (see desiredByOwner note),
365
- // and MCP-referenced credentials (e.g. a Figma token) are user-global by
366
- // nature, so user scope is the right resolution surface here.
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
- const result = await client.listTools();
416
- for (const tool of result.tools) {
417
- const registered = buildRegisteredTool(serverName, tool);
418
- // Register with an executor that calls the MCP server
419
- this.toolRegistry.registerTool(registered, async (args) => {
420
- const callResult = await client.callTool({
421
- name: tool.name,
422
- arguments: stripInternalToolArgs(args),
423
- });
424
- // Extract text + image content from the result. Image blobs
425
- // are spilled to ~/.code-shell/mcp_images/ so they don't bloat
426
- // the LLM message tree same pattern as the GenerateImage
427
- // tool and the model sees a textual reference it can Read
428
- // on a later turn if it needs the pixels. This sidesteps the
429
- // "MCP returned a 5MB screenshot token budget exploded"
430
- // failure mode that bit Codex (issue #11845); we never put
431
- // raw base64 image data in the result text. See
432
- // TODO-week.md #9c + docs/research-cc-vs-codex-image-handling.md §B.
433
- const parts = [];
434
- if (Array.isArray(callResult.content)) {
435
- for (const item of callResult.content) {
436
- if (typeof item === "string") {
437
- parts.push(item);
438
- continue;
439
- }
440
- if (typeof item !== "object" || item === null)
441
- continue;
442
- if ("text" in item) {
443
- parts.push(String(item.text));
444
- continue;
445
- }
446
- if (item.type === "image") {
447
- const block = item;
448
- if (typeof block.data === "string" && block.data.length > 0) {
449
- const note = await spillMcpImage(serverName, tool.name, block.data, block.mimeType ?? "image/png");
450
- parts.push(note);
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
- const body = parts.join("\n") || "(no output)";
456
- // Trust boundary: MCP output is external content. Wrap it so the
457
- // model sees an explicit reminder that the body comes from an
458
- // untrusted server and any instructions inside are content, not
459
- // commands. The marker is intentionally short so it doesn't bloat
460
- // every tool result, but distinct enough that prompt-injected
461
- // strings can't fake their way out.
462
- return wrapMcpOutput(serverName, tool.name, body);
463
- });
464
- const set = this.registeredToolsByServer.get(serverName) ?? new Set();
465
- set.add(registered.name);
466
- this.registeredToolsByServer.set(serverName, set);
467
- logger.info("mcp.tool_registered", { server: serverName, tool: registered.name });
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
- askChains.set(chainKey, new Promise((r) => (release = r)));
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.