@cjhyy/code-shell-core 0.6.0-rc.9 → 0.7.0

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 (193) 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 +4 -0
  7. package/dist/cc-orchestrator/agent-adapter.js +11 -1
  8. package/dist/cc-orchestrator/codex-session-history.d.ts +14 -1
  9. package/dist/cc-orchestrator/codex-session-history.js +65 -5
  10. package/dist/cc-orchestrator/cwd-normalize.d.ts +2 -0
  11. package/dist/cc-orchestrator/cwd-normalize.js +19 -0
  12. package/dist/cc-orchestrator/external-agent-bindings.d.ts +27 -0
  13. package/dist/cc-orchestrator/external-agent-bindings.js +150 -0
  14. package/dist/cc-orchestrator/external-agent-changes.js +22 -5
  15. package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -0
  16. package/dist/cc-orchestrator/external-agent-driver.js +265 -50
  17. package/dist/cc-orchestrator/external-agent-session-store.d.ts +23 -0
  18. package/dist/cc-orchestrator/external-agent-session-store.js +146 -0
  19. package/dist/cc-orchestrator/session-history.d.ts +35 -0
  20. package/dist/cc-orchestrator/session-history.js +98 -15
  21. package/dist/cli/agent-server-stdio.js +9 -2
  22. package/dist/context/compaction.d.ts +8 -1
  23. package/dist/context/compaction.js +49 -4
  24. package/dist/context/manager.d.ts +16 -2
  25. package/dist/context/manager.js +103 -19
  26. package/dist/credentials/access.d.ts +57 -0
  27. package/dist/credentials/access.js +185 -0
  28. package/dist/credentials/index.d.ts +3 -1
  29. package/dist/credentials/index.js +2 -0
  30. package/dist/credentials/inject-credential-tool.d.ts +3 -1
  31. package/dist/credentials/inject-credential-tool.js +30 -11
  32. package/dist/credentials/oauth.d.ts +20 -0
  33. package/dist/credentials/oauth.js +114 -0
  34. package/dist/credentials/store.d.ts +1 -0
  35. package/dist/credentials/store.js +3 -1
  36. package/dist/credentials/types.d.ts +49 -3
  37. package/dist/credentials/use-credential-tool.d.ts +9 -1
  38. package/dist/credentials/use-credential-tool.js +58 -45
  39. package/dist/engine/engine.d.ts +23 -3
  40. package/dist/engine/engine.js +380 -255
  41. package/dist/engine/goal.d.ts +17 -0
  42. package/dist/engine/goal.js +16 -6
  43. package/dist/engine/image-policy.d.ts +6 -0
  44. package/dist/engine/image-policy.js +17 -6
  45. package/dist/engine/input-attachments.d.ts +13 -0
  46. package/dist/engine/input-attachments.js +398 -0
  47. package/dist/engine/model-facade.d.ts +5 -2
  48. package/dist/engine/model-facade.js +4 -4
  49. package/dist/engine/parse-task.d.ts +10 -0
  50. package/dist/engine/parse-task.js +5 -0
  51. package/dist/engine/run-image-input.d.ts +22 -0
  52. package/dist/engine/run-image-input.js +195 -0
  53. package/dist/engine/steer-queue.d.ts +3 -1
  54. package/dist/engine/steer-queue.js +10 -2
  55. package/dist/engine/streaming-tool-queue.d.ts +11 -7
  56. package/dist/engine/streaming-tool-queue.js +11 -7
  57. package/dist/engine/turn-loop.d.ts +37 -2
  58. package/dist/engine/turn-loop.js +228 -43
  59. package/dist/engine/types.d.ts +8 -0
  60. package/dist/git/worktree/crud.d.ts +69 -0
  61. package/dist/git/worktree/crud.js +206 -0
  62. package/dist/git/worktree/diff.d.ts +14 -0
  63. package/dist/git/worktree/diff.js +82 -0
  64. package/dist/git/worktree/git-exec.d.ts +7 -0
  65. package/dist/git/worktree/git-exec.js +51 -0
  66. package/dist/git/worktree/index.d.ts +5 -0
  67. package/dist/git/worktree/index.js +5 -0
  68. package/dist/git/worktree/query.d.ts +42 -0
  69. package/dist/git/worktree/query.js +121 -0
  70. package/dist/git/worktree/slug.d.ts +11 -0
  71. package/dist/git/worktree/slug.js +58 -0
  72. package/dist/git/worktree.d.ts +1 -84
  73. package/dist/git/worktree.js +5 -230
  74. package/dist/hooks/goal-stop-hook.d.ts +33 -1
  75. package/dist/hooks/goal-stop-hook.js +202 -34
  76. package/dist/index.d.ts +31 -28
  77. package/dist/index.js +27 -25
  78. package/dist/logging/logger.js +6 -6
  79. package/dist/logging/sanitize-messages.d.ts +10 -2
  80. package/dist/logging/sanitize-messages.js +21 -6
  81. package/dist/plugins/installer/checkUpdate.d.ts +4 -1
  82. package/dist/plugins/installer/checkUpdate.js +4 -2
  83. package/dist/plugins/installer/install.js +2 -0
  84. package/dist/plugins/installer/installFromSource.js +9 -1
  85. package/dist/plugins/installer/parseSource.d.ts +4 -1
  86. package/dist/plugins/installer/parseSource.js +28 -10
  87. package/dist/plugins/installer/sourcePath.d.ts +9 -0
  88. package/dist/plugins/installer/sourcePath.js +50 -0
  89. package/dist/plugins/installer/update.d.ts +4 -1
  90. package/dist/plugins/installer/update.js +5 -3
  91. package/dist/plugins/parseMarketplaceInput.d.ts +4 -1
  92. package/dist/plugins/parseMarketplaceInput.js +4 -3
  93. package/dist/plugins/pluginInstaller.js +24 -22
  94. package/dist/preset/index.d.ts +1 -0
  95. package/dist/preset/index.js +30 -13
  96. package/dist/prompt/sections/base.md +1 -1
  97. package/dist/protocol/chat-session-manager.d.ts +2 -0
  98. package/dist/protocol/chat-session-manager.js +38 -2
  99. package/dist/protocol/chat-session.d.ts +3 -0
  100. package/dist/protocol/chat-session.js +1 -0
  101. package/dist/protocol/client.d.ts +9 -4
  102. package/dist/protocol/client.js +18 -1
  103. package/dist/protocol/server.d.ts +30 -0
  104. package/dist/protocol/server.js +247 -47
  105. package/dist/protocol/types.d.ts +49 -0
  106. package/dist/protocol/types.js +6 -0
  107. package/dist/run/FileRunStore.js +10 -1
  108. package/dist/run/Heartbeat.js +12 -0
  109. package/dist/run/RunApprovalBackend.d.ts +3 -0
  110. package/dist/run/RunApprovalBackend.js +41 -6
  111. package/dist/run/RunLock.js +2 -0
  112. package/dist/run/RunManager.d.ts +2 -0
  113. package/dist/run/RunManager.js +64 -24
  114. package/dist/run/ids.d.ts +2 -0
  115. package/dist/run/ids.js +23 -0
  116. package/dist/runtime/background-shell.d.ts +1 -0
  117. package/dist/runtime/background-shell.js +23 -13
  118. package/dist/runtime/spawn-common.js +10 -0
  119. package/dist/services/auto-dream.d.ts +15 -4
  120. package/dist/services/auto-dream.js +20 -20
  121. package/dist/services/dream-consolidation.d.ts +2 -3
  122. package/dist/services/dream-consolidation.js +65 -15
  123. package/dist/services/extract-memories.d.ts +14 -5
  124. package/dist/services/extract-memories.js +20 -3
  125. package/dist/services/global-dream-promotion.d.ts +23 -0
  126. package/dist/services/global-dream-promotion.js +112 -0
  127. package/dist/services/memory-orchestrator.js +347 -34
  128. package/dist/session/memory.d.ts +61 -18
  129. package/dist/session/memory.js +342 -79
  130. package/dist/session/session-manager.d.ts +35 -1
  131. package/dist/session/session-manager.js +224 -4
  132. package/dist/session/transcript.d.ts +1 -1
  133. package/dist/session/transcript.js +17 -5
  134. package/dist/settings/manager.d.ts +1 -0
  135. package/dist/settings/manager.js +87 -37
  136. package/dist/settings/schema-export.d.ts +2 -3
  137. package/dist/settings/schema-export.js +2 -3
  138. package/dist/settings/schema.d.ts +21 -0
  139. package/dist/settings/schema.js +12 -0
  140. package/dist/skills/scanner.d.ts +3 -2
  141. package/dist/skills/scanner.js +12 -9
  142. package/dist/tool-system/builtin/agent-notifications.d.ts +11 -4
  143. package/dist/tool-system/builtin/agent-notifications.js +19 -7
  144. package/dist/tool-system/builtin/background-jobs.d.ts +35 -6
  145. package/dist/tool-system/builtin/background-jobs.js +116 -7
  146. package/dist/tool-system/builtin/background-work.d.ts +17 -18
  147. package/dist/tool-system/builtin/background-work.js +51 -5
  148. package/dist/tool-system/builtin/config.d.ts +2 -1
  149. package/dist/tool-system/builtin/config.js +16 -11
  150. package/dist/tool-system/builtin/cron-list.definition.d.ts +3 -0
  151. package/dist/tool-system/builtin/cron-list.definition.js +6 -0
  152. package/dist/tool-system/builtin/cron.d.ts +1 -2
  153. package/dist/tool-system/builtin/cron.js +9 -7
  154. package/dist/tool-system/builtin/drive-claude-code.d.ts +23 -2
  155. package/dist/tool-system/builtin/drive-claude-code.js +589 -48
  156. package/dist/tool-system/builtin/edit.js +5 -2
  157. package/dist/tool-system/builtin/generate-video.d.ts +1 -0
  158. package/dist/tool-system/builtin/generate-video.js +13 -4
  159. package/dist/tool-system/builtin/index.d.ts +8 -3
  160. package/dist/tool-system/builtin/index.js +50 -22
  161. package/dist/tool-system/builtin/lsp.d.ts +2 -1
  162. package/dist/tool-system/builtin/lsp.js +6 -3
  163. package/dist/tool-system/builtin/memory.js +40 -8
  164. package/dist/tool-system/builtin/notebook-edit.js +5 -2
  165. package/dist/tool-system/builtin/powershell.d.ts +5 -2
  166. package/dist/tool-system/builtin/powershell.js +11 -7
  167. package/dist/tool-system/builtin/read.js +118 -6
  168. package/dist/tool-system/builtin/sleep.d.ts +1 -2
  169. package/dist/tool-system/builtin/sleep.definition.d.ts +8 -0
  170. package/dist/tool-system/builtin/sleep.definition.js +28 -0
  171. package/dist/tool-system/builtin/sleep.js +1 -22
  172. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  173. package/dist/tool-system/builtin/view-image.js +109 -19
  174. package/dist/tool-system/builtin/worktree.d.ts +4 -4
  175. package/dist/tool-system/builtin/worktree.js +297 -74
  176. package/dist/tool-system/builtin/write.js +5 -3
  177. package/dist/tool-system/context.d.ts +41 -1
  178. package/dist/tool-system/executor.d.ts +1 -5
  179. package/dist/tool-system/executor.js +94 -115
  180. package/dist/tool-system/mcp-manager.d.ts +31 -6
  181. package/dist/tool-system/mcp-manager.js +193 -78
  182. package/dist/tool-system/path-policy.d.ts +2 -0
  183. package/dist/tool-system/path-policy.js +41 -12
  184. package/dist/tool-system/permission.d.ts +28 -7
  185. package/dist/tool-system/permission.js +130 -49
  186. package/dist/tool-system/registry.js +11 -4
  187. package/dist/tool-system/tool-result-redaction.d.ts +7 -0
  188. package/dist/tool-system/tool-result-redaction.js +48 -0
  189. package/dist/tool-system/workspace-bridge.d.ts +11 -0
  190. package/dist/tool-system/workspace-bridge.js +1 -0
  191. package/dist/types.d.ts +78 -11
  192. package/dist/utils/toolDisplay.js +1 -1
  193. package/package.json +4 -3
@@ -7,11 +7,13 @@ 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
+ import { buildOAuthRefreshRequest, oauthCredentialStatus, parseOAuthCredentialSecret, } from "../credentials/oauth.js";
11
12
  import { ENV_ALLOWLIST } from "../runtime/spawn-common.js";
12
- import { writeFile, mkdir } from "node:fs/promises";
13
+ import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
13
14
  import { join } from "node:path";
14
15
  import { diagnoseMcpStdioMissingCommand, previewPath } from "./mcp-stdio-diagnostics.js";
16
+ import { codeShellHome } from "../session/session-manager.js";
15
17
  /**
16
18
  * Read a required secret from `process.env` by NAME (Codex-style env-secret
17
19
  * handling — the value is never persisted in MCP config). A referenced env var
@@ -72,14 +74,14 @@ export function buildStdioEnv(serverName, config) {
72
74
  * base; env-sourced secrets (`bearerTokenEnvVar`, `envHeaders`) layer on top
73
75
  * and win on conflict. Pure + exported for unit testing.
74
76
  */
75
- export function buildHttpHeaders(serverName, config, resolveCredential) {
77
+ export function buildHttpHeaders(serverName, config, resolveCredential, options = {}) {
76
78
  const headers = { ...(config.headers ?? {}) };
77
79
  if (config.credentialRef) {
78
- const secret = resolveCredential?.(config.credentialRef);
79
- if (secret === undefined || secret === "") {
80
+ const credential = normalizeResolvedMcpCredential(resolveCredential?.(config.credentialRef));
81
+ if (!credential || credential.secret === "") {
80
82
  throw new Error(`MCP server "${serverName}": credential "${config.credentialRef}" not found or empty`);
81
83
  }
82
- headers["Authorization"] = `Bearer ${secret}`;
84
+ headers["Authorization"] = `Bearer ${bearerTokenFromMcpCredential(serverName, config.credentialRef, credential, options)}`;
83
85
  }
84
86
  else if (config.bearerTokenEnvVar) {
85
87
  headers["Authorization"] = `Bearer ${readRequiredEnv(serverName, "bearerTokenEnvVar", config.bearerTokenEnvVar)}`;
@@ -89,6 +91,68 @@ export function buildHttpHeaders(serverName, config, resolveCredential) {
89
91
  }
90
92
  return headers;
91
93
  }
94
+ function normalizeResolvedMcpCredential(value) {
95
+ if (typeof value === "string")
96
+ return { secret: value };
97
+ if (!value)
98
+ return undefined;
99
+ return value;
100
+ }
101
+ export function bearerTokenFromMcpCredential(serverName, credentialId, credential, options = {}) {
102
+ if (credential.type === undefined)
103
+ return credential.secret;
104
+ if (credential.type !== "oauth") {
105
+ if (credential.type !== "token" && credential.type !== "link") {
106
+ throw new Error(`MCP server "${serverName}": credential "${credentialId}" type "${credential.type}" cannot be used as a bearer token`);
107
+ }
108
+ try {
109
+ const parsed = JSON.parse(credential.secret);
110
+ if (parsed !== null && typeof parsed === "object") {
111
+ throw new Error(`MCP server "${serverName}": credential "${credentialId}" resolved to a structured secret that does not match token metadata; refusing bearer injection`);
112
+ }
113
+ }
114
+ catch (err) {
115
+ if (err instanceof SyntaxError)
116
+ return credential.secret;
117
+ throw err;
118
+ }
119
+ return credential.secret;
120
+ }
121
+ const secret = parseOAuthCredentialSecret(credential.secret);
122
+ const status = oauthCredentialStatus(secret, {
123
+ now: options.now,
124
+ skewMs: options.oauthRefreshSkewMs,
125
+ });
126
+ if (status.state === "expired") {
127
+ const refresh = buildOAuthRefreshRequest(credentialId, secret);
128
+ const refreshHint = refresh
129
+ ? "refresh data is present, but automatic OAuth refresh is reserved and not wired yet"
130
+ : "missing refreshToken or tokenEndpoint for automatic refresh";
131
+ throw new Error(`MCP server "${serverName}": oauth credential "${credentialId}" access token expired; ${refreshHint}`);
132
+ }
133
+ return secret.accessToken;
134
+ }
135
+ export async function buildHttpHeadersWithCredentialAccess(serverName, config, access = getCredentialAccess(), options = {}) {
136
+ if (!config.credentialRef)
137
+ return buildHttpHeaders(serverName, config, undefined, options);
138
+ if (!access.resolveValue) {
139
+ throw new Error(`MCP server "${serverName}": credential "${config.credentialRef}" not found or empty`);
140
+ }
141
+ const meta = access.resolveMeta?.(undefined, config.credentialRef, "full");
142
+ if (!meta) {
143
+ throw new Error(`MCP server "${serverName}": credential "${config.credentialRef}" metadata is unavailable; refusing bearer injection`);
144
+ }
145
+ if (meta.type !== "token" && meta.type !== "link" && meta.type !== "oauth") {
146
+ throw new Error(`MCP server "${serverName}": credential "${config.credentialRef}" type "${meta.type}" cannot be used as a bearer token`);
147
+ }
148
+ const secret = await access.resolveValue({
149
+ cwd: undefined,
150
+ id: config.credentialRef,
151
+ scope: "full",
152
+ purpose: "mcp",
153
+ });
154
+ return buildHttpHeaders(serverName, config, (id) => id === config.credentialRef ? { secret, type: meta?.type, label: meta?.label } : undefined, options);
155
+ }
92
156
  /**
93
157
  * Infer the transport when the config doesn't name one: a url-only entry is
94
158
  * HTTP, everything else stdio. This is the CC `.mcp.json` convention —
@@ -118,7 +182,29 @@ export function inferTransportType(config) {
118
182
  * spill itself is bounded by the byte budget below; this cap is just
119
183
  * to keep ls(~/.code-shell/mcp_images) tractable.
120
184
  */
185
+ const MAX_MCP_IMAGE_SPILLS_PER_TOOL = 50;
121
186
  const MAX_MCP_IMAGE_BYTES = 8 * 1024 * 1024;
187
+ function spillTimestamp(filename, prefix) {
188
+ const suffix = filename.slice(prefix.length);
189
+ const dot = suffix.lastIndexOf(".");
190
+ const raw = dot === -1 ? suffix : suffix.slice(0, dot);
191
+ const timestamp = Number(raw);
192
+ return Number.isFinite(timestamp) ? timestamp : 0;
193
+ }
194
+ async function pruneMcpImageSpills(baseDir, prefix) {
195
+ const entries = await readdir(baseDir, { withFileTypes: true });
196
+ const spills = entries
197
+ .filter((entry) => entry.isFile() && entry.name.startsWith(prefix))
198
+ .map((entry) => ({
199
+ name: entry.name,
200
+ timestamp: spillTimestamp(entry.name, prefix),
201
+ }))
202
+ .sort((a, b) => a.timestamp - b.timestamp || a.name.localeCompare(b.name));
203
+ const excess = spills.length - MAX_MCP_IMAGE_SPILLS_PER_TOOL;
204
+ if (excess <= 0)
205
+ return;
206
+ await Promise.all(spills.slice(0, excess).map((entry) => unlink(join(baseDir, entry.name)).catch(() => { })));
207
+ }
122
208
  /**
123
209
  * Persist an MCP-returned image to disk and return the textual
124
210
  * reference the LLM should see. Images larger than
@@ -130,9 +216,7 @@ const MAX_MCP_IMAGE_BYTES = 8 * 1024 * 1024;
130
216
  * [mcp-image] server=playwright tool=screenshot saved=/abs/path.png (123 KB)
131
217
  */
132
218
  export async function spillMcpImage(serverName, toolName, base64, mimeType, opts) {
133
- const home = process.env.CODE_SHELL_HOME ?? process.env.HOME ?? "";
134
- const baseDir = opts?.baseDir ??
135
- (home ? join(home, ".code-shell", "mcp_images") : join("/tmp", "code-shell-mcp-images"));
219
+ const baseDir = opts?.baseDir ?? join(codeShellHome(), "mcp_images");
136
220
  const now = opts?.now ?? Date.now;
137
221
  const decodedBytes = Math.floor((base64.length * 3) / 4);
138
222
  if (decodedBytes > MAX_MCP_IMAGE_BYTES) {
@@ -147,11 +231,13 @@ export async function spillMcpImage(serverName, toolName, base64, mimeType, opts
147
231
  : "png";
148
232
  const safeServer = serverName.replace(/[^\w.-]+/g, "_");
149
233
  const safeTool = toolName.replace(/[^\w.-]+/g, "_");
150
- const filename = `${safeServer}-${safeTool}-${now()}.${ext}`;
234
+ const prefix = `${safeServer}-${safeTool}-`;
235
+ const filename = `${prefix}${now()}.${ext}`;
151
236
  const filePath = join(baseDir, filename);
152
237
  try {
153
238
  await mkdir(baseDir, { recursive: true });
154
239
  await writeFile(filePath, Buffer.from(base64, "base64"));
240
+ await pruneMcpImageSpills(baseDir, prefix);
155
241
  }
156
242
  catch (err) {
157
243
  logger.warn("mcp.image_spill_failed", {
@@ -205,7 +291,10 @@ export function buildRegisteredTool(serverName, tool) {
205
291
  return {
206
292
  name: toOpenAIToolName(`mcp_${serverName}_${tool.name}`),
207
293
  description: `[${serverName}] ${tool.description ?? tool.name}`,
208
- inputSchema: tool.inputSchema ?? { type: "object", properties: {} },
294
+ inputSchema: tool.inputSchema ?? {
295
+ type: "object",
296
+ properties: {},
297
+ },
209
298
  source: "mcp",
210
299
  serverName,
211
300
  permissionDefault: "ask",
@@ -247,13 +336,16 @@ export class MCPManager {
247
336
  * Connect to all configured MCP servers and register their tools.
248
337
  */
249
338
  async connectAll(servers, owner) {
339
+ const enabledNames = this.enabledServerNames(servers);
250
340
  // Register this owner's desired set up front (see reconcile's shared-pool
251
341
  // note) so a later reconcile from ANOTHER session can't disconnect servers
252
342
  // this session connected at run start.
253
343
  if (owner !== undefined) {
254
- this.desiredByOwner.set(owner, new Set(Object.entries(servers)
255
- .filter(([, c]) => c.enabled !== false)
256
- .map(([n]) => n)));
344
+ this.desiredByOwner.set(owner, enabledNames);
345
+ this.desiredServerNames = this.unionDesired() ?? new Set();
346
+ }
347
+ else if (this.desiredByOwner.size === 0) {
348
+ this.desiredServerNames = enabledNames;
257
349
  }
258
350
  // Codex-style toggle: skip servers explicitly disabled in settings.
259
351
  // Only the literal `false` disables — absent / true / any other value
@@ -279,26 +371,42 @@ export class MCPManager {
279
371
  }
280
372
  }
281
373
  async reconcile(servers, owner) {
282
- const enabledNames = new Set(Object.entries(servers)
283
- .filter(([, config]) => config.enabled !== false)
284
- .map(([name]) => name));
285
- this.desiredServerNames = enabledNames;
374
+ const enabledNames = this.enabledServerNames(servers);
286
375
  // Shared-pool semantics: this ONE pool serves every session in the worker,
287
376
  // and sessions in different projects legitimately want DIFFERENT server
288
377
  // sets (per-project capabilityOverrides). Disconnect only servers that NO
289
378
  // registered owner wants — otherwise the per-session hot-reload patches
290
379
  // would thrash each other's connections (last reconcile wins, killing a
291
- // server another project's session is using). Owners are engines; a
292
- // closed session's desires linger (no engine-teardown hook), which merely
293
- // over-RETAINS idle connections — per-session tool visibility keeps
294
- // correctness regardless.
380
+ // server another project's session is using). Owners are engines; closed
381
+ // sessions call unregisterOwner() so their desired sets stop retaining idle
382
+ // connections.
295
383
  if (owner !== undefined)
296
384
  this.desiredByOwner.set(owner, enabledNames);
297
385
  const union = this.unionDesired() ?? enabledNames;
386
+ this.desiredServerNames = union;
298
387
  const stale = this.listServers().filter((name) => !union.has(name));
299
388
  await Promise.all(stale.map((name) => this.disconnect(name)));
300
389
  await this.connectAll(servers, owner);
301
390
  }
391
+ /**
392
+ * Remove one owner from the shared desired-server pool and disconnect servers
393
+ * that no remaining owner wants. This is called when a session-owned Engine
394
+ * is closed; without it project-scoped MCP servers can stay connected until
395
+ * worker shutdown.
396
+ */
397
+ async unregisterOwner(owner) {
398
+ if (!this.desiredByOwner.delete(owner))
399
+ return;
400
+ const desired = this.unionDesired() ?? new Set();
401
+ this.desiredServerNames = desired;
402
+ const stale = this.listServers().filter((name) => !desired.has(name));
403
+ await Promise.all(stale.map((name) => this.disconnect(name)));
404
+ }
405
+ enabledServerNames(servers) {
406
+ return new Set(Object.entries(servers)
407
+ .filter(([, config]) => config.enabled !== false)
408
+ .map(([name]) => name));
409
+ }
302
410
  /** Union of every registered owner's desired set; null when none registered. */
303
411
  unionDesired() {
304
412
  if (this.desiredByOwner.size === 0)
@@ -361,12 +469,10 @@ export class MCPManager {
361
469
  if (!config.url) {
362
470
  throw new Error(`MCP server "${name}": url is required for ${transportType} transport`);
363
471
  }
364
- // credentialRef resolves against the user-scope CredentialStore. The
365
- // shared MCPManager pool has no per-session cwd (see desiredByOwner note),
366
- // and MCP-referenced credentials (e.g. a Figma token) are user-global by
367
- // nature, so user scope is the right resolution surface here.
368
- const credStore = new CredentialStore(undefined);
369
- const headers = buildHttpHeaders(name, config, (id) => credStore.resolve(id)?.secret);
472
+ // credentialRef resolves against user-scope credential access. The shared
473
+ // MCPManager pool has no per-session cwd, and MCP-referenced credentials
474
+ // (e.g. a Figma token) remain user-global by design.
475
+ const headers = await buildHttpHeadersWithCredentialAccess(name, config);
370
476
  transport = new StreamableHTTPClientTransport(new URL(config.url), {
371
477
  requestInit: Object.keys(headers).length ? { headers } : undefined,
372
478
  });
@@ -429,59 +535,65 @@ export class MCPManager {
429
535
  * Discover tools from an MCP server and register them.
430
536
  */
431
537
  async discoverTools(serverName, client) {
432
- const result = await client.listTools();
433
- for (const tool of result.tools) {
434
- const registered = buildRegisteredTool(serverName, tool);
435
- // Register with an executor that calls the MCP server
436
- this.toolRegistry.registerTool(registered, async (args) => {
437
- const callResult = await client.callTool({
438
- name: tool.name,
439
- arguments: stripInternalToolArgs(args),
440
- });
441
- // Extract text + image content from the result. Image blobs
442
- // are spilled to ~/.code-shell/mcp_images/ so they don't bloat
443
- // the LLM message tree same pattern as the GenerateImage
444
- // tool and the model sees a textual reference it can Read
445
- // on a later turn if it needs the pixels. This sidesteps the
446
- // "MCP returned a 5MB screenshot token budget exploded"
447
- // failure mode that bit Codex (issue #11845); we never put
448
- // raw base64 image data in the result text. See
449
- // TODO-week.md #9c + docs/research-cc-vs-codex-image-handling.md §B.
450
- const parts = [];
451
- if (Array.isArray(callResult.content)) {
452
- for (const item of callResult.content) {
453
- if (typeof item === "string") {
454
- parts.push(item);
455
- continue;
456
- }
457
- if (typeof item !== "object" || item === null)
458
- continue;
459
- if ("text" in item) {
460
- parts.push(String(item.text));
461
- continue;
462
- }
463
- if (item.type === "image") {
464
- const block = item;
465
- if (typeof block.data === "string" && block.data.length > 0) {
466
- const note = await spillMcpImage(serverName, tool.name, block.data, block.mimeType ?? "image/png");
467
- parts.push(note);
538
+ try {
539
+ const result = await client.listTools();
540
+ for (const tool of result.tools) {
541
+ const registered = buildRegisteredTool(serverName, tool);
542
+ // Register with an executor that calls the MCP server
543
+ this.toolRegistry.registerTool(registered, async (args, ctx) => {
544
+ const callResult = await client.callTool({
545
+ name: tool.name,
546
+ arguments: stripInternalToolArgs(args),
547
+ }, undefined, ctx?.signal ? { signal: ctx.signal } : undefined);
548
+ // Extract text + image content from the result. Image blobs
549
+ // are spilled to ~/.code-shell/mcp_images/ so they don't bloat
550
+ // the LLM message tree same pattern as the GenerateImage
551
+ // tool and the model sees a textual reference it can Read
552
+ // on a later turn if it needs the pixels. This sidesteps the
553
+ // "MCP returned a 5MB screenshot token budget exploded"
554
+ // failure mode that bit Codex (issue #11845); we never put
555
+ // raw base64 image data in the result text. See
556
+ // TODO-week.md #9c + docs/research-cc-vs-codex-image-handling.md §B.
557
+ const parts = [];
558
+ if (Array.isArray(callResult.content)) {
559
+ for (const item of callResult.content) {
560
+ if (typeof item === "string") {
561
+ parts.push(item);
562
+ continue;
563
+ }
564
+ if (typeof item !== "object" || item === null)
565
+ continue;
566
+ if ("text" in item) {
567
+ parts.push(String(item.text));
568
+ continue;
569
+ }
570
+ if (item.type === "image") {
571
+ const block = item;
572
+ if (typeof block.data === "string" && block.data.length > 0) {
573
+ const note = await spillMcpImage(serverName, tool.name, block.data, block.mimeType ?? "image/png");
574
+ parts.push(note);
575
+ }
468
576
  }
469
577
  }
470
578
  }
471
- }
472
- const body = parts.join("\n") || "(no output)";
473
- // Trust boundary: MCP output is external content. Wrap it so the
474
- // model sees an explicit reminder that the body comes from an
475
- // untrusted server and any instructions inside are content, not
476
- // commands. The marker is intentionally short so it doesn't bloat
477
- // every tool result, but distinct enough that prompt-injected
478
- // strings can't fake their way out.
479
- return wrapMcpOutput(serverName, tool.name, body);
480
- });
481
- const set = this.registeredToolsByServer.get(serverName) ?? new Set();
482
- set.add(registered.name);
483
- this.registeredToolsByServer.set(serverName, set);
484
- logger.info("mcp.tool_registered", { server: serverName, tool: registered.name });
579
+ const body = parts.join("\n") || "(no output)";
580
+ // Trust boundary: MCP output is external content. Wrap it so the
581
+ // model sees an explicit reminder that the body comes from an
582
+ // untrusted server and any instructions inside are content, not
583
+ // commands. The marker is intentionally short so it doesn't bloat
584
+ // every tool result, but distinct enough that prompt-injected
585
+ // strings can't fake their way out.
586
+ return wrapMcpOutput(serverName, tool.name, body);
587
+ });
588
+ const set = this.registeredToolsByServer.get(serverName) ?? new Set();
589
+ set.add(registered.name);
590
+ this.registeredToolsByServer.set(serverName, set);
591
+ logger.info("mcp.tool_registered", { server: serverName, tool: registered.name });
592
+ }
593
+ }
594
+ catch (err) {
595
+ await this.disconnect(serverName);
596
+ throw err;
485
597
  }
486
598
  }
487
599
  /**
@@ -489,6 +601,8 @@ export class MCPManager {
489
601
  */
490
602
  async disconnectAll() {
491
603
  await Promise.all([...this.connections.keys()].map((name) => this.disconnect(name)));
604
+ this.desiredByOwner.clear();
605
+ this.desiredServerNames = null;
492
606
  }
493
607
  async disconnect(name) {
494
608
  const conn = this.connections.get(name);
@@ -562,6 +676,7 @@ export class MCPManager {
562
676
  uri: r.uri,
563
677
  name: r.name ?? r.uri,
564
678
  description: r.description,
679
+ serverName: name,
565
680
  });
566
681
  }
567
682
  }
@@ -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.
@@ -21,6 +21,17 @@ export declare class AutoApprovalBackend implements ApprovalBackend {
21
21
  requestApproval(req: ApprovalRequest): Promise<ApprovalResult>;
22
22
  private isSafeOperation;
23
23
  }
24
+ /**
25
+ * Closed-session tombstones are a bounded replay guard. A prompt that started
26
+ * before close is still protected by the state-object identity check in
27
+ * isActiveSessionState(): clearSession deletes the captured state, so the late
28
+ * result cannot write into a new bucket even if an old tombstone has aged out.
29
+ * The tombstone only blocks fresh post-close calls carrying a recently closed
30
+ * sessionId from creating an empty bucket. 4096 is 256x the default
31
+ * ChatSessionManager.maxSessions (16), which covers normal late-result bursts
32
+ * while keeping long-lived processes from retaining every historical id.
33
+ */
34
+ export declare const CLOSED_SESSION_TOMBSTONE_LIMIT = 4096;
24
35
  /**
25
36
  * Interactive approval backend — prompts the user via a callback.
26
37
  *
@@ -34,13 +45,11 @@ export declare class AutoApprovalBackend implements ApprovalBackend {
34
45
  * the next time the user opens the project.
35
46
  */
36
47
  export declare class InteractiveApprovalBackend implements ApprovalBackend {
37
- private sessionAllowRules;
38
- private sessionDenyRules;
48
+ private sessionStateById;
49
+ private closedSessionIds;
39
50
  private promptFn;
40
- private cwd;
41
- private savedProjectRules;
42
- private onProjectRules;
43
- private promptTurn;
51
+ private legacyPromptTurn;
52
+ private legacyContext;
44
53
  setPromptFn(fn: (request: ApprovalRequest) => Promise<ApprovalResult>): void;
45
54
  /**
46
55
  * Has someone installed a real prompt callback? Engine consults this
@@ -59,8 +68,18 @@ export declare class InteractiveApprovalBackend implements ApprovalBackend {
59
68
  * earlier approvals.
60
69
  */
61
70
  setOnProjectRules(fn: (rules: PermissionRule[]) => void): void;
71
+ setSessionContext(sessionId: string, context: {
72
+ cwd: string;
73
+ onProjectRules: (rules: PermissionRule[]) => void;
74
+ }): void;
75
+ openSession(sessionId: string): void;
76
+ clearSession(sessionId: string): void;
77
+ private rememberClosedSession;
78
+ private makeSessionState;
79
+ private getSessionState;
80
+ private isActiveSessionState;
62
81
  requestApproval(req: ApprovalRequest): Promise<ApprovalResult>;
63
- /** Session-rule lookup — operation-scoped (see sessionAllowRules doc). Deny
82
+ /** Session-rule lookup — operation-scoped (see sessionStateById doc). Deny
64
83
  * wins over allow if both somehow match (conservative). Null = no rule. */
65
84
  private checkSessionRules;
66
85
  /** The actual interactive ask + rule recording (runs inside the prompt turn). */
@@ -89,6 +108,8 @@ export declare function pathRuleArgsPattern(absPath: string, scope: "file" | "di
89
108
  */
90
109
  export declare function ruleMatches(rule: PermissionRule, toolName: string, args: Record<string, unknown>): boolean;
91
110
  export declare function getInteractiveApprovalBackend(): InteractiveApprovalBackend;
111
+ export declare function openInteractiveApprovalSession(sessionId: string): void;
112
+ export declare function clearInteractiveApprovalSession(sessionId: string): void;
92
113
  export declare function setInteractiveApprovalFn(fn: (request: ApprovalRequest) => Promise<ApprovalResult>): void;
93
114
  type BashSafetyLevel = "safe-read" | "safe-write" | "unsafe" | "dangerous";
94
115
  /**