@cjhyy/code-shell-core 0.7.0 → 0.7.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 (118) hide show
  1. package/dist/arena/arena.d.ts +2 -0
  2. package/dist/arena/arena.js +30 -1
  3. package/dist/arena/phases/adjudication.d.ts +2 -1
  4. package/dist/arena/phases/adjudication.js +2 -1
  5. package/dist/arena/phases/build-consensus.d.ts +2 -1
  6. package/dist/arena/phases/build-consensus.js +3 -1
  7. package/dist/arena/phases/cross-review.d.ts +3 -1
  8. package/dist/arena/phases/cross-review.js +8 -2
  9. package/dist/arena/phases/debate-rounds.d.ts +2 -1
  10. package/dist/arena/phases/debate-rounds.js +4 -2
  11. package/dist/arena/phases/participant-research.d.ts +2 -1
  12. package/dist/arena/phases/participant-research.js +3 -1
  13. package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
  14. package/dist/arena/phases/planning-detail-expansion.js +2 -1
  15. package/dist/arena/planner.d.ts +2 -1
  16. package/dist/arena/planner.js +2 -1
  17. package/dist/arena/types.d.ts +5 -1
  18. package/dist/cli/agent-server-tcp.js +13 -2
  19. package/dist/context/manager.d.ts +3 -3
  20. package/dist/context/manager.js +6 -6
  21. package/dist/context/token-counter.js +5 -3
  22. package/dist/context/tool-result-storage.d.ts +6 -0
  23. package/dist/context/tool-result-storage.js +25 -4
  24. package/dist/credentials/access.d.ts +10 -1
  25. package/dist/credentials/access.js +76 -2
  26. package/dist/credentials/index.d.ts +3 -2
  27. package/dist/credentials/index.js +2 -1
  28. package/dist/credentials/oauth.d.ts +6 -1
  29. package/dist/credentials/oauth.js +66 -1
  30. package/dist/credentials/store.d.ts +1 -1
  31. package/dist/credentials/store.js +16 -7
  32. package/dist/credentials/types.d.ts +38 -1
  33. package/dist/credentials/types.js +16 -1
  34. package/dist/engine/engine.d.ts +61 -32
  35. package/dist/engine/engine.js +289 -55
  36. package/dist/engine/goal.d.ts +5 -3
  37. package/dist/engine/session-title.d.ts +2 -1
  38. package/dist/engine/session-title.js +4 -1
  39. package/dist/engine/turn-loop.d.ts +18 -0
  40. package/dist/engine/turn-loop.js +202 -27
  41. package/dist/engine/types.d.ts +6 -2
  42. package/dist/git/worktree/crud.d.ts +3 -0
  43. package/dist/git/worktree/crud.js +32 -3
  44. package/dist/git/worktree/git-exec.d.ts +2 -2
  45. package/dist/git/worktree/git-exec.js +47 -11
  46. package/dist/git/worktree/query.d.ts +8 -7
  47. package/dist/git/worktree/query.js +27 -20
  48. package/dist/hooks/events.d.ts +3 -0
  49. package/dist/hooks/events.js +0 -3
  50. package/dist/hooks/goal-stop-hook.d.ts +14 -4
  51. package/dist/hooks/goal-stop-hook.js +626 -71
  52. package/dist/hooks/registry.js +3 -0
  53. package/dist/hooks/shell-runner.d.ts +12 -1
  54. package/dist/hooks/shell-runner.js +160 -9
  55. package/dist/index.d.ts +7 -6
  56. package/dist/index.js +6 -5
  57. package/dist/llm/client-base.js +12 -10
  58. package/dist/llm/types.d.ts +12 -5
  59. package/dist/plugins/pluginCommandHook.d.ts +4 -4
  60. package/dist/plugins/pluginCommandHook.js +111 -13
  61. package/dist/protocol/chat-session-manager.d.ts +13 -2
  62. package/dist/protocol/chat-session-manager.js +90 -18
  63. package/dist/protocol/chat-session.d.ts +12 -0
  64. package/dist/protocol/chat-session.js +30 -5
  65. package/dist/protocol/client.d.ts +5 -2
  66. package/dist/protocol/client.js +22 -1
  67. package/dist/protocol/server.d.ts +25 -11
  68. package/dist/protocol/server.js +290 -72
  69. package/dist/protocol/types.d.ts +34 -2
  70. package/dist/protocol/types.js +2 -0
  71. package/dist/services/dream-consolidation.d.ts +3 -0
  72. package/dist/services/dream-consolidation.js +4 -1
  73. package/dist/services/index.d.ts +1 -1
  74. package/dist/services/index.js +1 -1
  75. package/dist/services/oauth.d.ts +34 -10
  76. package/dist/services/oauth.js +233 -98
  77. package/dist/session/session-manager.d.ts +35 -6
  78. package/dist/session/session-manager.js +362 -26
  79. package/dist/session/transcript.d.ts +30 -1
  80. package/dist/session/transcript.js +119 -4
  81. package/dist/tool-system/builtin/agent.js +5 -1
  82. package/dist/tool-system/builtin/arena.js +1 -0
  83. package/dist/tool-system/builtin/background-jobs.d.ts +8 -5
  84. package/dist/tool-system/builtin/background-jobs.js +15 -11
  85. package/dist/tool-system/builtin/background-work.d.ts +6 -1
  86. package/dist/tool-system/builtin/background-work.js +5 -1
  87. package/dist/tool-system/builtin/bash.d.ts +3 -5
  88. package/dist/tool-system/builtin/bash.js +10 -5
  89. package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
  90. package/dist/tool-system/builtin/edit.d.ts +2 -1
  91. package/dist/tool-system/builtin/edit.js +12 -4
  92. package/dist/tool-system/builtin/generate-video.d.ts +4 -0
  93. package/dist/tool-system/builtin/generate-video.js +138 -21
  94. package/dist/tool-system/builtin/glob.d.ts +2 -1
  95. package/dist/tool-system/builtin/glob.js +28 -3
  96. package/dist/tool-system/builtin/grep.d.ts +1 -0
  97. package/dist/tool-system/builtin/grep.js +82 -17
  98. package/dist/tool-system/builtin/index.d.ts +25 -11
  99. package/dist/tool-system/builtin/index.js +45 -2
  100. package/dist/tool-system/builtin/video-providers.d.ts +12 -15
  101. package/dist/tool-system/builtin/video-providers.js +1 -0
  102. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  103. package/dist/tool-system/builtin/web-fetch.js +44 -3
  104. package/dist/tool-system/builtin/worktree.js +25 -7
  105. package/dist/tool-system/builtin/write.d.ts +2 -1
  106. package/dist/tool-system/builtin/write.js +14 -4
  107. package/dist/tool-system/context.d.ts +17 -5
  108. package/dist/tool-system/executor.js +24 -8
  109. package/dist/tool-system/mcp-manager.d.ts +6 -0
  110. package/dist/tool-system/mcp-manager.js +55 -5
  111. package/dist/tool-system/path-policy.d.ts +19 -0
  112. package/dist/tool-system/path-policy.js +62 -1
  113. package/dist/tool-system/permission.d.ts +43 -3
  114. package/dist/tool-system/permission.js +383 -30
  115. package/dist/tool-system/registry.d.ts +3 -2
  116. package/dist/tool-system/registry.js +52 -34
  117. package/dist/types.d.ts +15 -0
  118. package/package.json +1 -1
@@ -197,6 +197,7 @@ export class FalVideoProvider {
197
197
  const videoUrl = j.video?.url;
198
198
  if (!videoUrl)
199
199
  return { ok: false, error: "fal result: no video.url" };
200
+ req.onUrl?.(videoUrl);
200
201
  // hop 2: video bytes
201
202
  const vr = await this.fetchImpl(videoUrl, { method: "GET", signal: req.signal });
202
203
  if (!vr.ok) {
@@ -13,6 +13,6 @@
13
13
  */
14
14
  import type { ToolDefinition } from "../../types.js";
15
15
  import type { ToolContext } from "../context.js";
16
- import type { BuiltinToolResult } from "./index.js";
16
+ import type { BuiltinToolReturn } from "./index.js";
17
17
  export declare const viewImageToolDef: ToolDefinition;
18
- export declare function viewImageTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<BuiltinToolResult>;
18
+ export declare function viewImageTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<BuiltinToolReturn>;
@@ -25,6 +25,7 @@ export const webFetchToolDef = {
25
25
  };
26
26
  const MAX_OUTPUT = 100_000;
27
27
  const DEFAULT_MAX = 50_000;
28
+ const RAW_BYTE_BUDGET_MULTIPLIER = 4;
28
29
  // Header names that must not be overridable via args.headers (SSRF / auth injection)
29
30
  const BLOCKED_REQUEST_HEADERS = new Set([
30
31
  "host", "authorization", "cookie", "proxy-authorization",
@@ -252,7 +253,46 @@ export async function webFetchTool(args) {
252
253
  }
253
254
  async function readAndTruncateBody(res, maxLength) {
254
255
  const contentType = res.headers.get("content-type") ?? "";
255
- const body = await res.text();
256
+ const maxBytes = maxLength * RAW_BYTE_BUDGET_MULTIPLIER;
257
+ const reader = res.body?.getReader();
258
+ let body;
259
+ let rawTruncated = false;
260
+ if (!reader) {
261
+ body = await res.text();
262
+ }
263
+ else {
264
+ const decoder = new TextDecoder("utf-8");
265
+ const chunks = [];
266
+ let bytesRead = 0;
267
+ try {
268
+ for (;;) {
269
+ const { done, value } = await reader.read();
270
+ if (done)
271
+ break;
272
+ const remaining = maxBytes - bytesRead;
273
+ if (value.byteLength >= remaining) {
274
+ chunks.push(decoder.decode(value.subarray(0, remaining), { stream: true }));
275
+ bytesRead += remaining;
276
+ rawTruncated = true;
277
+ await reader.cancel();
278
+ break;
279
+ }
280
+ chunks.push(decoder.decode(value, { stream: true }));
281
+ bytesRead += value.byteLength;
282
+ }
283
+ const tail = decoder.decode();
284
+ // When the byte budget cuts through a multi-byte code point, flushing
285
+ // the decoder would turn that intentionally incomplete suffix into �.
286
+ // All complete code points were already emitted by stream:true, so the
287
+ // truncated tail is safe to discard.
288
+ if (!rawTruncated)
289
+ chunks.push(tail);
290
+ body = chunks.join("");
291
+ }
292
+ finally {
293
+ reader.releaseLock();
294
+ }
295
+ }
256
296
  let text;
257
297
  if (contentType.includes("text/html") || contentType.includes("application/xhtml")) {
258
298
  text = extractTextFromHTML(body);
@@ -260,8 +300,9 @@ async function readAndTruncateBody(res, maxLength) {
260
300
  else {
261
301
  text = body;
262
302
  }
263
- if (text.length > maxLength) {
264
- text = text.slice(0, maxLength) + `\n\n... content truncated (${text.length} chars total)`;
303
+ if (rawTruncated || text.length > maxLength) {
304
+ const shownChars = Math.min(text.length, maxLength);
305
+ text = text.slice(0, maxLength) + `\n\n... content truncated (showing first ${shownChars} chars)`;
265
306
  }
266
307
  return text || "(page returned empty content)";
267
308
  }
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { existsSync } from "node:fs";
5
5
  import { isAbsolute, resolve } from "node:path";
6
- import { createWorktree, removeWorktree, listWorktrees, validateWorktreeSlug, selectPlatformScript, runWorktreeSetup, worktreeHasUncommittedOrAheadChanges, currentBranch, } from "../../git/worktree.js";
6
+ import { createWorktree, removeWorktree, listWorktrees, validateWorktreeSlug, selectPlatformScript, runWorktreeSetup, worktreeHasUncommittedOrAheadChanges, currentBranch, cleanupAbortedWorktree, } from "../../git/worktree.js";
7
7
  export const switchSessionWorkspaceToolDef = {
8
8
  name: "SwitchSessionWorkspace",
9
9
  description: "Switch this current conversation session into or out of a workspace through the host UI path. " +
@@ -44,7 +44,7 @@ export async function switchSessionWorkspaceTool(args, ctx) {
44
44
  nextTurnNotice(workspace.root, ctx?.cwd ?? workspace.root));
45
45
  }
46
46
  catch (err) {
47
- return `Error switching workspace: ${err.message}`;
47
+ return `Error: switching workspace failed: ${err.message}`;
48
48
  }
49
49
  }
50
50
  export const enterWorktreeToolDef = {
@@ -86,6 +86,7 @@ export async function enterWorktreeTool(args, ctx) {
86
86
  const branchPrefix = ctx?.engine?.readWorktreeBranchPrefix?.(mainRoot);
87
87
  try {
88
88
  if (target === "main") {
89
+ ctx?.signal?.throwIfAborted();
89
90
  const workspace = { root: mainRoot, kind: "main" };
90
91
  persistSessionWorkspace(sessionManager, sessionId, workspace, ctx);
91
92
  sessionManager.recordWorkspaceHandoff(sessionId, fromWorkspace, workspace);
@@ -101,13 +102,26 @@ export async function enterWorktreeTool(args, ctx) {
101
102
  sessionId,
102
103
  currentWorkspace: fromWorkspace,
103
104
  branchPrefix,
105
+ signal: ctx?.signal,
104
106
  });
107
+ if (ctx?.signal?.aborted) {
108
+ if (selected.created) {
109
+ await cleanupAbortedWorktree(mainRoot, selected.session.worktreePath, selected.session.worktreeBranch);
110
+ }
111
+ throw new Error("Worktree creation aborted");
112
+ }
105
113
  const workspace = toSessionWorkspace(selected, fromWorkspace);
106
- persistSessionWorkspace(sessionManager, sessionId, workspace, ctx);
107
- sessionManager.recordWorkspaceHandoff(sessionId, fromWorkspace, workspace);
108
114
  const setupNote = selected.created
109
115
  ? await runSetupIfConfigured(selected.session.worktreePath, mainRoot, ctx)
110
116
  : "";
117
+ if (ctx?.signal?.aborted) {
118
+ if (selected.created) {
119
+ await cleanupAbortedWorktree(mainRoot, selected.session.worktreePath, selected.session.worktreeBranch);
120
+ }
121
+ throw new Error("Worktree creation aborted");
122
+ }
123
+ persistSessionWorkspace(sessionManager, sessionId, workspace, ctx);
124
+ sessionManager.recordWorkspaceHandoff(sessionId, fromWorkspace, workspace);
111
125
  const verb = selected.created ? "Worktree created and switched" : "Switched to worktree";
112
126
  return (`${verb}:\n` +
113
127
  ` Path: ${workspace.worktree.path}\n` +
@@ -118,7 +132,7 @@ export async function enterWorktreeTool(args, ctx) {
118
132
  setupNote);
119
133
  }
120
134
  catch (err) {
121
- return `Error switching worktree: ${err.message}`;
135
+ return `Error: switching worktree failed: ${err.message}`;
122
136
  }
123
137
  }
124
138
  /** Keep setup output from bloating the tool result — head+tail-ish trim. */
@@ -229,7 +243,7 @@ export async function exitWorktreeTool(args, ctx) {
229
243
  nextTurnNotice(mainRoot, currentTurnRoot));
230
244
  }
231
245
  catch (err) {
232
- return `Error exiting worktree: ${err.message}`;
246
+ return `Error: exiting worktree failed: ${err.message}`;
233
247
  }
234
248
  }
235
249
  function sessionServices(ctx) {
@@ -261,6 +275,7 @@ function sharedWorktreeRemovalSkippedMessage(otherOwners, mainRoot, currentTurnR
261
275
  }
262
276
  async function resolveWorktreeTarget(opts) {
263
277
  const entries = await listWorktrees(opts.mainRoot);
278
+ opts.signal?.throwIfAborted();
264
279
  const pathTarget = pathLike(opts.target) ? resolvePathTarget(opts.target, opts.cwd) : undefined;
265
280
  const branchTarget = normalizeBranchName(opts.target);
266
281
  const match = entries.find((entry) => {
@@ -269,6 +284,8 @@ async function resolveWorktreeTarget(opts) {
269
284
  return entry.branch === branchTarget;
270
285
  });
271
286
  if (match) {
287
+ const originalBranch = await currentBranch(opts.mainRoot);
288
+ opts.signal?.throwIfAborted();
272
289
  return {
273
290
  created: false,
274
291
  session: {
@@ -276,7 +293,7 @@ async function resolveWorktreeTarget(opts) {
276
293
  worktreePath: match.path,
277
294
  worktreeName: match.path.split(/[\\/]/).pop() ?? match.branch,
278
295
  worktreeBranch: match.branch,
279
- originalBranch: await currentBranch(opts.mainRoot),
296
+ originalBranch,
280
297
  sessionId: opts.sessionId,
281
298
  createdAt: Date.now(),
282
299
  },
@@ -289,6 +306,7 @@ async function resolveWorktreeTarget(opts) {
289
306
  validateWorktreeSlug(opts.target);
290
307
  const created = await createWorktree(opts.mainRoot, opts.target, opts.sessionId, {
291
308
  prefix: opts.branchPrefix,
309
+ signal: opts.signal,
292
310
  });
293
311
  return { created: true, session: created, from: created.originalBranch ?? "HEAD" };
294
312
  }
@@ -3,5 +3,6 @@
3
3
  */
4
4
  import type { ToolDefinition } from "../../types.js";
5
5
  import type { ToolContext } from "../context.js";
6
+ import type { ToolFailure } from "./index.js";
6
7
  export declare const writeToolDef: ToolDefinition;
7
- export declare function writeTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<string>;
8
+ export declare function writeTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<string | ToolFailure>;
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Built-in Write file tool.
3
3
  */
4
- import { writeFile, mkdir } from "node:fs/promises";
4
+ import { mkdir } from "node:fs/promises";
5
5
  import { dirname, isAbsolute, resolve } from "node:path";
6
6
  import { fileCache } from "./file-cache.js";
7
+ import { getFinalWritePathSnapshot, revalidateFinalWritePath, writeFileNoFollow, } from "../path-policy.js";
7
8
  export const writeToolDef = {
8
9
  name: "Write",
9
10
  description: "Write content to a file. Creates the file if it doesn't exist, " +
@@ -27,12 +28,21 @@ export async function writeTool(args, ctx) {
27
28
  const cwd = ctx?.cwd ?? process.cwd();
28
29
  const filePath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
29
30
  try {
30
- await mkdir(dirname(filePath), { recursive: true });
31
- await writeFile(filePath, content, "utf-8");
31
+ const approvedPath = getFinalWritePathSnapshot(args, filePath, cwd);
32
+ const beforeMkdir = revalidateFinalWritePath(filePath, cwd, approvedPath);
33
+ if ("error" in beforeMkdir)
34
+ return { ok: false, error: beforeMkdir.error };
35
+ await mkdir(dirname(beforeMkdir.resolvedPath), { recursive: true });
36
+ // mkdir may have crossed an existing symlink in a missing parent chain;
37
+ // resolve again immediately before opening the final file.
38
+ const beforeWrite = revalidateFinalWritePath(filePath, cwd, approvedPath);
39
+ if ("error" in beforeWrite)
40
+ return { ok: false, error: beforeWrite.error };
41
+ await writeFileNoFollow(beforeWrite.resolvedPath, content);
32
42
  fileCache.invalidate(filePath);
33
43
  return `Successfully wrote to ${filePath}`;
34
44
  }
35
45
  catch (err) {
36
- return `Error writing file: ${err.message}`;
46
+ return { ok: false, error: `Error writing file: ${err.message}` };
37
47
  }
38
48
  }
@@ -12,7 +12,7 @@
12
12
  * Tools that don't need any context (Read/Write/Bash/...) just ignore
13
13
  * the second argument; the type is purely additive.
14
14
  */
15
- import type { LLMConfig, StreamCallback } from "../types.js";
15
+ import type { LLMConfig, StreamCallback, TokenUsage } from "../types.js";
16
16
  import type { ModelPool } from "../llm/model-pool.js";
17
17
  import type { ToolRegistry } from "./registry.js";
18
18
  import type { AgentPresetName } from "../preset/index.js";
@@ -20,6 +20,7 @@ import type { SandboxBackend } from "./sandbox/index.js";
20
20
  import type { HookRegistry } from "../hooks/registry.js";
21
21
  import type { SessionManager } from "../session/session-manager.js";
22
22
  import type { SessionWorkspace } from "../types.js";
23
+ import type { ApprovalRouter } from "./permission.js";
23
24
  /**
24
25
  * Narrow view of the owning Engine that tools are allowed to call back into.
25
26
  * Defined here (in the low-level tool-system) rather than importing the
@@ -154,10 +155,7 @@ export interface SubAgentSpawner {
154
155
  * (or `resumeSessionId` when resuming), so the parent can address follow-up
155
156
  * input at the same session via AgentSendInput (transcript replay).
156
157
  */
157
- spawn(req: SubAgentSpawnRequest): Promise<{
158
- text: string;
159
- sessionId: string;
160
- }>;
158
+ spawn(req: SubAgentSpawnRequest): Promise<SubAgentSpawnResult>;
161
159
  /**
162
160
  * Whether a child session id exists on disk. Lets AgentSendInput resume an
163
161
  * agent across a process restart (when the in-memory registry is empty but
@@ -173,6 +171,12 @@ export interface SubAgentSpawner {
173
171
  permissionMode: string;
174
172
  };
175
173
  }
174
+ export interface SubAgentSpawnResult {
175
+ text: string;
176
+ sessionId: string;
177
+ /** Complete billed usage returned by the child Engine run. */
178
+ usage?: TokenUsage;
179
+ }
176
180
  /**
177
181
  * The full context object handed to every tool invocation.
178
182
  *
@@ -247,6 +251,14 @@ export interface ToolContext {
247
251
  * headless/test mode; tools must tolerate absence.
248
252
  */
249
253
  streamCallback?: StreamCallback;
254
+ /**
255
+ * Report provider usage produced by a tool or child Engine into the owning
256
+ * session and run-scoped Goal budget. This must not invoke the process-wide
257
+ * billing hook again; the originating provider client already did that.
258
+ */
259
+ recordBilledUsage?: (usage: TokenUsage) => void;
260
+ /** Connection-scoped approval owner router supplied by the protocol host. */
261
+ approvalRouter?: ApprovalRouter;
250
262
  /** Whether the owning Engine is currently in plan mode. Replaces the
251
263
  * removed module-level `isInPlanMode()` singleton. */
252
264
  planMode: boolean;
@@ -9,7 +9,7 @@ import { logger as rootLogger, getCurrentSid } from "../logging/logger.js";
9
9
  import { recordToolCall, recordToolResult } from "../logging/session-recorder.js";
10
10
  import { validateToolArgs } from "./validation.js";
11
11
  import { PLAN_MODE_ALLOWED_TOOLS } from "./plan-mode-allowlist.js";
12
- import { enforcePathPolicyWithApproval } from "./path-policy.js";
12
+ import { attachFinalWritePathSnapshot, enforcePathPolicyWithApproval, } from "./path-policy.js";
13
13
  import { parsePatch } from "./builtin/apply-patch/parser.js";
14
14
  import { BUILTIN_TOOL_GUARDS } from "./builtin/index.js";
15
15
  import { COMPLETE_GOAL_TOOL_NAME } from "./builtin/complete-goal.js";
@@ -244,6 +244,7 @@ export class ToolExecutor {
244
244
  toolName: call.toolName,
245
245
  args: call.args,
246
246
  toolCallId: call.id,
247
+ signal: this.signal ?? this.toolCtx?.signal,
247
248
  });
248
249
  if (hookResult.decision === "deny") {
249
250
  return {
@@ -280,6 +281,16 @@ export class ToolExecutor {
280
281
  // path surface on RegisteredTool.pathPolicy; the executor enforces it
281
282
  // after hooks have had a chance to rewrite args, but before permission
282
283
  // classification or the handler can touch the filesystem.
284
+ if ((call.toolName === "Write" || call.toolName === "Edit") &&
285
+ this.toolCtx?.cwd &&
286
+ typeof call.args.file_path === "string") {
287
+ const raw = call.args.file_path;
288
+ const target = isAbsolutePath(raw) ? raw : resolvePath(this.toolCtx.cwd, raw);
289
+ call = {
290
+ ...call,
291
+ args: attachFinalWritePathSnapshot(call.args, target, this.toolCtx.cwd),
292
+ };
293
+ }
283
294
  const pathPolicyError = await this.enforceDeclaredPathPolicy(toolDef, call.args);
284
295
  if (pathPolicyError) {
285
296
  return {
@@ -325,6 +336,7 @@ export class ToolExecutor {
325
336
  args: call.args,
326
337
  toolCallId: call.id,
327
338
  classifierDecision,
339
+ signal: this.signal ?? this.toolCtx?.signal,
328
340
  });
329
341
  const preClamped = clampHookDecision(classifierDecision, hookResult.decision);
330
342
  let decision = preClamped.decision;
@@ -383,6 +395,7 @@ export class ToolExecutor {
383
395
  toolName: call.toolName,
384
396
  args: call.args,
385
397
  toolCallId: call.id,
398
+ signal: this.signal ?? this.toolCtx?.signal,
386
399
  });
387
400
  // 3. Execute. Use a span so begin/end share one cat and end carries
388
401
  // duration_ms; widen args truncation from 200→2000 so Edit/Write payloads
@@ -431,25 +444,25 @@ export class ToolExecutor {
431
444
  result.id = call.id;
432
445
  // Prepend any non-blocking guard reminder onto a successful result so the
433
446
  // model sees it on its next turn alongside the content it just fetched.
434
- if (guardDecision?.prepend && !result.error && result.result) {
447
+ if (guardDecision?.prepend && !result.isError && result.result) {
435
448
  result.result = `${guardDecision.prepend}\n${result.result}`;
436
449
  }
437
450
  const observerResult = toolResultDisplayText(result);
438
451
  const payload = observerResult ?? result.error ?? "";
439
452
  span.end({
440
- ok: !result.error,
453
+ ok: !result.isError,
441
454
  chars: payload.length,
442
455
  // Snippet only on failure — successful tool output can be huge and is
443
456
  // already on the transcript. Failures are rare and the first 500 chars
444
457
  // usually contain the message we need.
445
- ...(result.error ? { errorSnippet: payload.slice(0, 500) } : {}),
458
+ ...(result.isError ? { errorSnippet: payload.slice(0, 500) } : {}),
446
459
  });
447
460
  recordToolResult(sid, {
448
461
  id: call.id,
449
462
  toolName: call.toolName,
450
- ok: !result.error,
463
+ ok: !result.isError,
451
464
  durationMs: Date.now() - toolStartedAt,
452
- output: result.error ? undefined : observerResult,
465
+ output: result.isError ? undefined : observerResult,
453
466
  error: result.error,
454
467
  });
455
468
  // 4. Post-tool hook
@@ -458,6 +471,7 @@ export class ToolExecutor {
458
471
  toolCallId: call.id,
459
472
  result: observerResult,
460
473
  error: result.error,
474
+ signal: this.signal ?? this.toolCtx?.signal,
461
475
  });
462
476
  // 5. Post-tool-use hook (after execution, can observe/modify result)
463
477
  const postHook = await this.hooks.emit("post_tool_use", {
@@ -465,13 +479,14 @@ export class ToolExecutor {
465
479
  toolCallId: call.id,
466
480
  result: observerResult,
467
481
  error: result.error,
482
+ signal: this.signal ?? this.toolCtx?.signal,
468
483
  });
469
484
  // Append handler-supplied context (linter output, type-check result,
470
485
  // etc.) onto the tool result so the model sees it on the next turn.
471
486
  // Tagged with a separator so the model can tell hook output from
472
487
  // tool output. Skip when the tool errored — additional context on a
473
488
  // failed tool would be confusing.
474
- if (postHook.additionalContext && !result.error) {
489
+ if (postHook.additionalContext && !result.isError) {
475
490
  const tag = "--- additional context from post_tool_use hook ---";
476
491
  result.result = result.result
477
492
  ? `${result.result}\n\n${tag}\n${postHook.additionalContext}`
@@ -484,10 +499,11 @@ export class ToolExecutor {
484
499
  });
485
500
  }
486
501
  // 6. file_changed hook for Write/Edit tools
487
- if ((call.toolName === "Write" || call.toolName === "Edit") && !result.error) {
502
+ if ((call.toolName === "Write" || call.toolName === "Edit") && !result.isError) {
488
503
  await this.hooks.emit("file_changed", {
489
504
  toolName: call.toolName,
490
505
  filePath: call.args.file_path,
506
+ signal: this.signal ?? this.toolCtx?.signal,
491
507
  });
492
508
  }
493
509
  return result;
@@ -48,6 +48,12 @@ export declare function buildStdioEnv(serverName: string, config: MCPServerConfi
48
48
  export declare function buildHttpHeaders(serverName: string, config: MCPServerConfig, resolveCredential?: (id: string) => HttpCredentialResolverResult, options?: BuildHttpHeadersOptions): Record<string, string>;
49
49
  export declare function bearerTokenFromMcpCredential(serverName: string, credentialId: string, credential: ResolvedMcpCredential, options?: BuildHttpHeadersOptions): string;
50
50
  export declare function buildHttpHeadersWithCredentialAccess(serverName: string, config: MCPServerConfig, access?: Pick<CredentialAccess, "resolveValue"> & Partial<Pick<CredentialAccess, "resolveMeta">>, options?: BuildHttpHeadersOptions): Promise<Record<string, string>>;
51
+ /**
52
+ * Fetch adapter for long-lived HTTP MCP transports. OAuth access tokens are
53
+ * resolved for every request, refreshed by the host inside the skew window,
54
+ * and force-refreshed once after a replayable 401.
55
+ */
56
+ export declare function createMcpAuthenticatedFetch(serverName: string, config: MCPServerConfig, access?: CredentialAccess, baseFetch?: typeof fetch): typeof fetch;
51
57
  /**
52
58
  * Infer the transport when the config doesn't name one: a url-only entry is
53
59
  * HTTP, everything else stdio. This is the CC `.mcp.json` convention —
@@ -11,6 +11,7 @@ import { getCredentialAccess } from "../credentials/access.js";
11
11
  import { buildOAuthRefreshRequest, oauthCredentialStatus, parseOAuthCredentialSecret, } from "../credentials/oauth.js";
12
12
  import { ENV_ALLOWLIST } from "../runtime/spawn-common.js";
13
13
  import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
14
+ import { randomUUID } from "node:crypto";
14
15
  import { join } from "node:path";
15
16
  import { diagnoseMcpStdioMissingCommand, previewPath } from "./mcp-stdio-diagnostics.js";
16
17
  import { codeShellHome } from "../session/session-manager.js";
@@ -153,6 +154,55 @@ export async function buildHttpHeadersWithCredentialAccess(serverName, config, a
153
154
  });
154
155
  return buildHttpHeaders(serverName, config, (id) => id === config.credentialRef ? { secret, type: meta?.type, label: meta?.label } : undefined, options);
155
156
  }
157
+ /**
158
+ * Fetch adapter for long-lived HTTP MCP transports. OAuth access tokens are
159
+ * resolved for every request, refreshed by the host inside the skew window,
160
+ * and force-refreshed once after a replayable 401.
161
+ */
162
+ export function createMcpAuthenticatedFetch(serverName, config, access = getCredentialAccess(), baseFetch = fetch) {
163
+ return (async (input, init) => {
164
+ const credentialId = config.credentialRef;
165
+ const meta = credentialId ? access.resolveMeta(undefined, credentialId, "full") : undefined;
166
+ const isOAuth = meta?.type === "oauth";
167
+ const headers = isOAuth
168
+ ? buildHttpHeaders(serverName, { ...config, credentialRef: undefined })
169
+ : await buildHttpHeadersWithCredentialAccess(serverName, config, access);
170
+ let token;
171
+ if (isOAuth) {
172
+ if (!access.resolveOAuthAccess) {
173
+ throw new Error(`MCP server "${serverName}": oauth credential "${credentialId}" requires host OAuth access`);
174
+ }
175
+ token = (await access.resolveOAuthAccess({ id: credentialId, scope: "full" })).accessToken;
176
+ }
177
+ const source = new Request(input, init);
178
+ let replayable = true;
179
+ try {
180
+ source.clone();
181
+ }
182
+ catch {
183
+ replayable = false;
184
+ }
185
+ const send = async (accessToken) => {
186
+ const request = replayable ? source.clone() : source;
187
+ const requestHeaders = new Headers(request.headers);
188
+ for (const [name, value] of Object.entries(headers))
189
+ requestHeaders.set(name, value);
190
+ if (accessToken)
191
+ requestHeaders.set("Authorization", `Bearer ${accessToken}`);
192
+ return baseFetch(new Request(request, { headers: requestHeaders }));
193
+ };
194
+ const first = await send(token);
195
+ if (first.status !== 401 || !isOAuth || !credentialId || !replayable || source.signal.aborted) {
196
+ return first;
197
+ }
198
+ const refreshed = await access.resolveOAuthAccess({
199
+ id: credentialId,
200
+ scope: "full",
201
+ forceRefresh: true,
202
+ });
203
+ return send(refreshed.accessToken);
204
+ });
205
+ }
156
206
  /**
157
207
  * Infer the transport when the config doesn't name one: a url-only entry is
158
208
  * HTTP, everything else stdio. This is the CC `.mcp.json` convention —
@@ -186,9 +236,7 @@ const MAX_MCP_IMAGE_SPILLS_PER_TOOL = 50;
186
236
  const MAX_MCP_IMAGE_BYTES = 8 * 1024 * 1024;
187
237
  function spillTimestamp(filename, prefix) {
188
238
  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);
239
+ const timestamp = Number(/^\d+/.exec(suffix)?.[0]);
192
240
  return Number.isFinite(timestamp) ? timestamp : 0;
193
241
  }
194
242
  async function pruneMcpImageSpills(baseDir, prefix) {
@@ -232,7 +280,7 @@ export async function spillMcpImage(serverName, toolName, base64, mimeType, opts
232
280
  const safeServer = serverName.replace(/[^\w.-]+/g, "_");
233
281
  const safeTool = toolName.replace(/[^\w.-]+/g, "_");
234
282
  const prefix = `${safeServer}-${safeTool}-`;
235
- const filename = `${prefix}${now()}.${ext}`;
283
+ const filename = `${prefix}${now()}-${randomUUID().slice(0, 8)}.${ext}`;
236
284
  const filePath = join(baseDir, filename);
237
285
  try {
238
286
  await mkdir(baseDir, { recursive: true });
@@ -472,9 +520,11 @@ export class MCPManager {
472
520
  // credentialRef resolves against user-scope credential access. The shared
473
521
  // MCPManager pool has no per-session cwd, and MCP-referenced credentials
474
522
  // (e.g. a Figma token) remain user-global by design.
475
- const headers = await buildHttpHeadersWithCredentialAccess(name, config);
523
+ const access = getCredentialAccess();
524
+ const headers = buildHttpHeaders(name, { ...config, credentialRef: undefined });
476
525
  transport = new StreamableHTTPClientTransport(new URL(config.url), {
477
526
  requestInit: Object.keys(headers).length ? { headers } : undefined,
527
+ fetch: createMcpAuthenticatedFetch(name, config, access),
478
528
  });
479
529
  }
480
530
  else {
@@ -49,11 +49,30 @@ export interface ClassifyOptions {
49
49
  /** "read" or "write" — different defaults for sensitive paths. */
50
50
  operation: PathOperation;
51
51
  }
52
+ export interface FinalWritePathSnapshot {
53
+ resolvedPath: string;
54
+ workspacePath: string;
55
+ insideWorkspace: boolean;
56
+ }
52
57
  export type PathApprovalScope = "once" | "session" | "project";
53
58
  /** Test seam: clear the in-memory session grants. */
54
59
  export declare function _resetSessionPathGrants(): void;
55
60
  export declare function openSessionPathApprovals(sessionId: string): void;
56
61
  export declare function clearSessionPathApprovals(sessionId: string): void;
62
+ /**
63
+ * Capture the concrete target approved by the executor's first path-policy
64
+ * pass. The snapshot is carried on the internal args object via a symbol, so
65
+ * it cannot collide with an LLM-supplied schema property or leak into logs.
66
+ */
67
+ export declare function attachFinalWritePathSnapshot(args: Record<string, unknown>, filePath: string, workspaceRoot: string): Record<string, unknown>;
68
+ export declare function getFinalWritePathSnapshot(args: Record<string, unknown>, filePath: string, workspaceRoot: string): FinalWritePathSnapshot;
69
+ export declare function revalidateFinalWritePath(filePath: string, workspaceRoot: string, approved: FinalWritePathSnapshot): {
70
+ resolvedPath: string;
71
+ } | {
72
+ error: string;
73
+ };
74
+ /** Open the already-revalidated final path without following its last segment. */
75
+ export declare function writeFileNoFollow(filePath: string, content: string): Promise<void>;
57
76
  /**
58
77
  * Classify a file path against the workspace + sensitive-path policy.
59
78
  *
@@ -29,10 +29,12 @@
29
29
  * approval round-trip; it is not an authority to write anywhere on disk.
30
30
  * Callers must consult classifyPath before honoring acceptEdits.
31
31
  */
32
- import { realpathSync, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, } from "node:fs";
32
+ import { constants, realpathSync, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, } from "node:fs";
33
+ import { open } from "node:fs/promises";
33
34
  import { homedir } from "node:os";
34
35
  import { randomUUID } from "node:crypto";
35
36
  import { dirname, isAbsolute, join, resolve as resolvePath, sep } from "node:path";
37
+ const FINAL_WRITE_PATH_SNAPSHOT = Symbol("codeshell.finalWritePathSnapshot");
36
38
  /**
37
39
  * Default sensitive path patterns. These are evaluated AFTER home-expansion
38
40
  * and resolution, so a literal "$HOME/.ssh" and a symlink at /tmp/x → ~/.ssh
@@ -325,6 +327,65 @@ function safeRealpath(p) {
325
327
  }
326
328
  return abs;
327
329
  }
330
+ /**
331
+ * Capture the concrete target approved by the executor's first path-policy
332
+ * pass. The snapshot is carried on the internal args object via a symbol, so
333
+ * it cannot collide with an LLM-supplied schema property or leak into logs.
334
+ */
335
+ export function attachFinalWritePathSnapshot(args, filePath, workspaceRoot) {
336
+ const resolvedPath = safeRealpath(filePath);
337
+ const workspacePath = safeRealpath(workspaceRoot);
338
+ const snapshot = {
339
+ resolvedPath,
340
+ workspacePath,
341
+ insideWorkspace: isInsideDir(resolvedPath, workspacePath),
342
+ };
343
+ return { ...args, [FINAL_WRITE_PATH_SNAPSHOT]: snapshot };
344
+ }
345
+ export function getFinalWritePathSnapshot(args, filePath, workspaceRoot) {
346
+ const carried = args[FINAL_WRITE_PATH_SNAPSHOT];
347
+ if (carried && typeof carried === "object") {
348
+ return carried;
349
+ }
350
+ // Direct tool calls do not pass through ToolExecutor. Preserve that API by
351
+ // taking the baseline at handler entry, while still protecting Edit's
352
+ // read-to-write interval with a second check before its writeFile.
353
+ const resolvedPath = safeRealpath(filePath);
354
+ const workspacePath = safeRealpath(workspaceRoot);
355
+ return {
356
+ resolvedPath,
357
+ workspacePath,
358
+ insideWorkspace: isInsideDir(resolvedPath, workspacePath),
359
+ };
360
+ }
361
+ export function revalidateFinalWritePath(filePath, workspaceRoot, approved) {
362
+ const currentPath = safeRealpath(filePath);
363
+ const currentWorkspace = safeRealpath(workspaceRoot);
364
+ const sameTarget = normPath(currentPath) === normPath(approved.resolvedPath);
365
+ const sameWorkspace = normPath(currentWorkspace) === normPath(approved.workspacePath);
366
+ const crossedWorkspaceBoundary = approved.insideWorkspace && !isInsideDir(currentPath, currentWorkspace);
367
+ if (crossedWorkspaceBoundary || !sameTarget || !sameWorkspace) {
368
+ const reason = crossedWorkspaceBoundary
369
+ ? "final write path resolved outside the workspace after approval"
370
+ : "final write path changed after approval";
371
+ return {
372
+ error: `Error: ${reason}; refusing to write. ` +
373
+ `Approved target: ${approved.resolvedPath}. Current target: ${currentPath}`,
374
+ };
375
+ }
376
+ return { resolvedPath: currentPath };
377
+ }
378
+ /** Open the already-revalidated final path without following its last segment. */
379
+ export async function writeFileNoFollow(filePath, content) {
380
+ const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW;
381
+ const handle = await open(filePath, flags, 0o666);
382
+ try {
383
+ await handle.writeFile(content, "utf-8");
384
+ }
385
+ finally {
386
+ await handle.close();
387
+ }
388
+ }
328
389
  function isInsideDir(child, parent) {
329
390
  const c = normPath(child);
330
391
  const par = normPath(parent);