@cjhyy/code-shell-core 0.7.0-beta.1 → 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 (146) 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/cc-orchestrator/agent-adapter.d.ts +2 -0
  19. package/dist/cc-orchestrator/agent-adapter.js +4 -0
  20. package/dist/cc-orchestrator/codex-session-history.d.ts +14 -1
  21. package/dist/cc-orchestrator/codex-session-history.js +64 -4
  22. package/dist/cc-orchestrator/external-agent-changes.js +22 -5
  23. package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -1
  24. package/dist/cc-orchestrator/external-agent-driver.js +202 -38
  25. package/dist/cc-orchestrator/session-history.d.ts +35 -0
  26. package/dist/cc-orchestrator/session-history.js +96 -13
  27. package/dist/cli/agent-server-tcp.js +13 -2
  28. package/dist/context/manager.d.ts +3 -3
  29. package/dist/context/manager.js +6 -6
  30. package/dist/context/token-counter.js +5 -3
  31. package/dist/context/tool-result-storage.d.ts +6 -0
  32. package/dist/context/tool-result-storage.js +25 -4
  33. package/dist/credentials/access.d.ts +11 -1
  34. package/dist/credentials/access.js +77 -1
  35. package/dist/credentials/index.d.ts +3 -1
  36. package/dist/credentials/index.js +2 -0
  37. package/dist/credentials/oauth.d.ts +25 -0
  38. package/dist/credentials/oauth.js +179 -0
  39. package/dist/credentials/store.d.ts +2 -1
  40. package/dist/credentials/store.js +19 -8
  41. package/dist/credentials/types.d.ts +84 -1
  42. package/dist/credentials/types.js +16 -1
  43. package/dist/engine/engine.d.ts +67 -34
  44. package/dist/engine/engine.js +448 -247
  45. package/dist/engine/goal.d.ts +19 -0
  46. package/dist/engine/goal.js +16 -6
  47. package/dist/engine/input-attachments.js +156 -13
  48. package/dist/engine/run-image-input.d.ts +22 -0
  49. package/dist/engine/run-image-input.js +195 -0
  50. package/dist/engine/session-title.d.ts +2 -1
  51. package/dist/engine/session-title.js +4 -1
  52. package/dist/engine/steer-queue.d.ts +3 -1
  53. package/dist/engine/steer-queue.js +10 -2
  54. package/dist/engine/turn-loop.d.ts +48 -1
  55. package/dist/engine/turn-loop.js +307 -37
  56. package/dist/engine/types.d.ts +6 -2
  57. package/dist/git/worktree/crud.d.ts +3 -0
  58. package/dist/git/worktree/crud.js +32 -3
  59. package/dist/git/worktree/git-exec.d.ts +2 -2
  60. package/dist/git/worktree/git-exec.js +47 -11
  61. package/dist/git/worktree/query.d.ts +8 -7
  62. package/dist/git/worktree/query.js +27 -20
  63. package/dist/hooks/events.d.ts +3 -0
  64. package/dist/hooks/events.js +0 -3
  65. package/dist/hooks/goal-stop-hook.d.ts +44 -2
  66. package/dist/hooks/goal-stop-hook.js +775 -52
  67. package/dist/hooks/registry.js +3 -0
  68. package/dist/hooks/shell-runner.d.ts +12 -1
  69. package/dist/hooks/shell-runner.js +160 -9
  70. package/dist/index.d.ts +7 -6
  71. package/dist/index.js +6 -5
  72. package/dist/llm/client-base.js +12 -10
  73. package/dist/llm/types.d.ts +12 -5
  74. package/dist/plugins/pluginCommandHook.d.ts +4 -4
  75. package/dist/plugins/pluginCommandHook.js +111 -13
  76. package/dist/preset/index.js +14 -4
  77. package/dist/protocol/chat-session-manager.d.ts +13 -2
  78. package/dist/protocol/chat-session-manager.js +90 -18
  79. package/dist/protocol/chat-session.d.ts +12 -0
  80. package/dist/protocol/chat-session.js +30 -5
  81. package/dist/protocol/client.d.ts +5 -2
  82. package/dist/protocol/client.js +22 -1
  83. package/dist/protocol/server.d.ts +25 -11
  84. package/dist/protocol/server.js +291 -73
  85. package/dist/protocol/types.d.ts +36 -2
  86. package/dist/protocol/types.js +2 -0
  87. package/dist/services/dream-consolidation.d.ts +3 -0
  88. package/dist/services/dream-consolidation.js +4 -1
  89. package/dist/services/index.d.ts +1 -1
  90. package/dist/services/index.js +1 -1
  91. package/dist/services/oauth.d.ts +34 -10
  92. package/dist/services/oauth.js +233 -98
  93. package/dist/session/session-manager.d.ts +35 -6
  94. package/dist/session/session-manager.js +396 -27
  95. package/dist/session/transcript.d.ts +30 -1
  96. package/dist/session/transcript.js +119 -4
  97. package/dist/tool-system/builtin/agent-notifications.d.ts +11 -4
  98. package/dist/tool-system/builtin/agent-notifications.js +19 -7
  99. package/dist/tool-system/builtin/agent.js +5 -1
  100. package/dist/tool-system/builtin/arena.js +1 -0
  101. package/dist/tool-system/builtin/background-jobs.d.ts +28 -5
  102. package/dist/tool-system/builtin/background-jobs.js +109 -7
  103. package/dist/tool-system/builtin/background-work.d.ts +6 -1
  104. package/dist/tool-system/builtin/background-work.js +5 -1
  105. package/dist/tool-system/builtin/bash.d.ts +3 -5
  106. package/dist/tool-system/builtin/bash.js +10 -5
  107. package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
  108. package/dist/tool-system/builtin/cron-list.definition.d.ts +3 -0
  109. package/dist/tool-system/builtin/cron-list.definition.js +6 -0
  110. package/dist/tool-system/builtin/cron.d.ts +1 -2
  111. package/dist/tool-system/builtin/cron.js +9 -7
  112. package/dist/tool-system/builtin/drive-claude-code.d.ts +7 -0
  113. package/dist/tool-system/builtin/drive-claude-code.js +307 -20
  114. package/dist/tool-system/builtin/edit.d.ts +2 -1
  115. package/dist/tool-system/builtin/edit.js +12 -4
  116. package/dist/tool-system/builtin/generate-video.d.ts +4 -0
  117. package/dist/tool-system/builtin/generate-video.js +138 -21
  118. package/dist/tool-system/builtin/glob.d.ts +2 -1
  119. package/dist/tool-system/builtin/glob.js +28 -3
  120. package/dist/tool-system/builtin/grep.d.ts +1 -0
  121. package/dist/tool-system/builtin/grep.js +82 -17
  122. package/dist/tool-system/builtin/index.d.ts +25 -11
  123. package/dist/tool-system/builtin/index.js +60 -5
  124. package/dist/tool-system/builtin/sleep.d.ts +1 -2
  125. package/dist/tool-system/builtin/sleep.definition.d.ts +8 -0
  126. package/dist/tool-system/builtin/sleep.definition.js +28 -0
  127. package/dist/tool-system/builtin/sleep.js +1 -22
  128. package/dist/tool-system/builtin/video-providers.d.ts +12 -15
  129. package/dist/tool-system/builtin/video-providers.js +1 -0
  130. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  131. package/dist/tool-system/builtin/web-fetch.js +44 -3
  132. package/dist/tool-system/builtin/worktree.js +25 -7
  133. package/dist/tool-system/builtin/write.d.ts +2 -1
  134. package/dist/tool-system/builtin/write.js +14 -4
  135. package/dist/tool-system/context.d.ts +35 -5
  136. package/dist/tool-system/executor.js +24 -8
  137. package/dist/tool-system/mcp-manager.d.ts +20 -2
  138. package/dist/tool-system/mcp-manager.js +111 -12
  139. package/dist/tool-system/path-policy.d.ts +19 -0
  140. package/dist/tool-system/path-policy.js +62 -1
  141. package/dist/tool-system/permission.d.ts +43 -3
  142. package/dist/tool-system/permission.js +383 -30
  143. package/dist/tool-system/registry.d.ts +3 -2
  144. package/dist/tool-system/registry.js +52 -34
  145. package/dist/types.d.ts +38 -7
  146. package/package.json +1 -1
@@ -8,8 +8,10 @@ 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
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
13
  import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
14
+ import { randomUUID } from "node:crypto";
13
15
  import { join } from "node:path";
14
16
  import { diagnoseMcpStdioMissingCommand, previewPath } from "./mcp-stdio-diagnostics.js";
15
17
  import { codeShellHome } from "../session/session-manager.js";
@@ -73,14 +75,14 @@ export function buildStdioEnv(serverName, config) {
73
75
  * base; env-sourced secrets (`bearerTokenEnvVar`, `envHeaders`) layer on top
74
76
  * and win on conflict. Pure + exported for unit testing.
75
77
  */
76
- export function buildHttpHeaders(serverName, config, resolveCredential) {
78
+ export function buildHttpHeaders(serverName, config, resolveCredential, options = {}) {
77
79
  const headers = { ...(config.headers ?? {}) };
78
80
  if (config.credentialRef) {
79
- const secret = resolveCredential?.(config.credentialRef);
80
- if (secret === undefined || secret === "") {
81
+ const credential = normalizeResolvedMcpCredential(resolveCredential?.(config.credentialRef));
82
+ if (!credential || credential.secret === "") {
81
83
  throw new Error(`MCP server "${serverName}": credential "${config.credentialRef}" not found or empty`);
82
84
  }
83
- headers["Authorization"] = `Bearer ${secret}`;
85
+ headers["Authorization"] = `Bearer ${bearerTokenFromMcpCredential(serverName, config.credentialRef, credential, options)}`;
84
86
  }
85
87
  else if (config.bearerTokenEnvVar) {
86
88
  headers["Authorization"] = `Bearer ${readRequiredEnv(serverName, "bearerTokenEnvVar", config.bearerTokenEnvVar)}`;
@@ -90,19 +92,116 @@ export function buildHttpHeaders(serverName, config, resolveCredential) {
90
92
  }
91
93
  return headers;
92
94
  }
93
- export async function buildHttpHeadersWithCredentialAccess(serverName, config, access = getCredentialAccess()) {
95
+ function normalizeResolvedMcpCredential(value) {
96
+ if (typeof value === "string")
97
+ return { secret: value };
98
+ if (!value)
99
+ return undefined;
100
+ return value;
101
+ }
102
+ export function bearerTokenFromMcpCredential(serverName, credentialId, credential, options = {}) {
103
+ if (credential.type === undefined)
104
+ return credential.secret;
105
+ if (credential.type !== "oauth") {
106
+ if (credential.type !== "token" && credential.type !== "link") {
107
+ throw new Error(`MCP server "${serverName}": credential "${credentialId}" type "${credential.type}" cannot be used as a bearer token`);
108
+ }
109
+ try {
110
+ const parsed = JSON.parse(credential.secret);
111
+ if (parsed !== null && typeof parsed === "object") {
112
+ throw new Error(`MCP server "${serverName}": credential "${credentialId}" resolved to a structured secret that does not match token metadata; refusing bearer injection`);
113
+ }
114
+ }
115
+ catch (err) {
116
+ if (err instanceof SyntaxError)
117
+ return credential.secret;
118
+ throw err;
119
+ }
120
+ return credential.secret;
121
+ }
122
+ const secret = parseOAuthCredentialSecret(credential.secret);
123
+ const status = oauthCredentialStatus(secret, {
124
+ now: options.now,
125
+ skewMs: options.oauthRefreshSkewMs,
126
+ });
127
+ if (status.state === "expired") {
128
+ const refresh = buildOAuthRefreshRequest(credentialId, secret);
129
+ const refreshHint = refresh
130
+ ? "refresh data is present, but automatic OAuth refresh is reserved and not wired yet"
131
+ : "missing refreshToken or tokenEndpoint for automatic refresh";
132
+ throw new Error(`MCP server "${serverName}": oauth credential "${credentialId}" access token expired; ${refreshHint}`);
133
+ }
134
+ return secret.accessToken;
135
+ }
136
+ export async function buildHttpHeadersWithCredentialAccess(serverName, config, access = getCredentialAccess(), options = {}) {
94
137
  if (!config.credentialRef)
95
- return buildHttpHeaders(serverName, config);
138
+ return buildHttpHeaders(serverName, config, undefined, options);
96
139
  if (!access.resolveValue) {
97
140
  throw new Error(`MCP server "${serverName}": credential "${config.credentialRef}" not found or empty`);
98
141
  }
142
+ const meta = access.resolveMeta?.(undefined, config.credentialRef, "full");
143
+ if (!meta) {
144
+ throw new Error(`MCP server "${serverName}": credential "${config.credentialRef}" metadata is unavailable; refusing bearer injection`);
145
+ }
146
+ if (meta.type !== "token" && meta.type !== "link" && meta.type !== "oauth") {
147
+ throw new Error(`MCP server "${serverName}": credential "${config.credentialRef}" type "${meta.type}" cannot be used as a bearer token`);
148
+ }
99
149
  const secret = await access.resolveValue({
100
150
  cwd: undefined,
101
151
  id: config.credentialRef,
102
152
  scope: "full",
103
153
  purpose: "mcp",
104
154
  });
105
- return buildHttpHeaders(serverName, config, (id) => id === config.credentialRef ? secret : undefined);
155
+ return buildHttpHeaders(serverName, config, (id) => id === config.credentialRef ? { secret, type: meta?.type, label: meta?.label } : undefined, options);
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
+ });
106
205
  }
107
206
  /**
108
207
  * Infer the transport when the config doesn't name one: a url-only entry is
@@ -137,9 +236,7 @@ const MAX_MCP_IMAGE_SPILLS_PER_TOOL = 50;
137
236
  const MAX_MCP_IMAGE_BYTES = 8 * 1024 * 1024;
138
237
  function spillTimestamp(filename, prefix) {
139
238
  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);
239
+ const timestamp = Number(/^\d+/.exec(suffix)?.[0]);
143
240
  return Number.isFinite(timestamp) ? timestamp : 0;
144
241
  }
145
242
  async function pruneMcpImageSpills(baseDir, prefix) {
@@ -183,7 +280,7 @@ export async function spillMcpImage(serverName, toolName, base64, mimeType, opts
183
280
  const safeServer = serverName.replace(/[^\w.-]+/g, "_");
184
281
  const safeTool = toolName.replace(/[^\w.-]+/g, "_");
185
282
  const prefix = `${safeServer}-${safeTool}-`;
186
- const filename = `${prefix}${now()}.${ext}`;
283
+ const filename = `${prefix}${now()}-${randomUUID().slice(0, 8)}.${ext}`;
187
284
  const filePath = join(baseDir, filename);
188
285
  try {
189
286
  await mkdir(baseDir, { recursive: true });
@@ -423,9 +520,11 @@ export class MCPManager {
423
520
  // credentialRef resolves against user-scope credential access. The shared
424
521
  // MCPManager pool has no per-session cwd, and MCP-referenced credentials
425
522
  // (e.g. a Figma token) remain user-global by design.
426
- const headers = await buildHttpHeadersWithCredentialAccess(name, config);
523
+ const access = getCredentialAccess();
524
+ const headers = buildHttpHeaders(name, { ...config, credentialRef: undefined });
427
525
  transport = new StreamableHTTPClientTransport(new URL(config.url), {
428
526
  requestInit: Object.keys(headers).length ? { headers } : undefined,
527
+ fetch: createMcpAuthenticatedFetch(name, config, access),
429
528
  });
430
529
  }
431
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);
@@ -6,6 +6,42 @@ import { logger as rootPermLogger } from "../logging/logger.js";
6
6
  export interface ApprovalBackend {
7
7
  requestApproval(request: ApprovalRequest): Promise<ApprovalResult>;
8
8
  }
9
+ export interface ApprovalRouteTarget {
10
+ connectionId: string;
11
+ sessionId: string;
12
+ generation: number;
13
+ }
14
+ export interface ApprovalConnectionHandler {
15
+ requestApproval(request: ApprovalRequest, target: ApprovalRouteTarget): Promise<ApprovalResult>;
16
+ ownershipLost?(targets: ApprovalRouteTarget[], reason: string): void;
17
+ }
18
+ export type ApprovalRegistrationResult = {
19
+ ok: true;
20
+ target: ApprovalRouteTarget;
21
+ } | {
22
+ ok: false;
23
+ conflict: ApprovalRouteTarget;
24
+ };
25
+ /**
26
+ * Process-local connection owner router. Handlers are connection-scoped while
27
+ * session ownership is an explicit binding with a monotonic generation.
28
+ */
29
+ export declare class ApprovalRouter {
30
+ private readonly handlers;
31
+ private readonly owners;
32
+ private readonly fallbackConnections;
33
+ private nextGeneration;
34
+ addConnection(connectionId: string, handler: ApprovalConnectionHandler, options?: {
35
+ claimUnownedSessions?: boolean;
36
+ }): () => void;
37
+ register(sessionId: string, connectionId: string): ApprovalRegistrationResult;
38
+ resolve(request: ApprovalRequest): Promise<ApprovalResult> | null;
39
+ matches(target: ApprovalRouteTarget): boolean;
40
+ current(sessionId: string): ApprovalRouteTarget | null;
41
+ release(sessionId: string, connectionId: string, reason?: string): void;
42
+ deregister(connectionId: string, reason?: string): void;
43
+ hasConnections(): boolean;
44
+ }
9
45
  export declare class HeadlessApprovalBackend implements ApprovalBackend {
10
46
  private readonly mode;
11
47
  constructor(mode: "approve-all" | "deny-all" | "approve-read-only");
@@ -45,18 +81,22 @@ export declare const CLOSED_SESSION_TOMBSTONE_LIMIT = 4096;
45
81
  * the next time the user opens the project.
46
82
  */
47
83
  export declare class InteractiveApprovalBackend implements ApprovalBackend {
84
+ private readonly approvalRouter;
48
85
  private sessionStateById;
49
86
  private closedSessionIds;
50
87
  private promptFn;
51
88
  private legacyPromptTurn;
52
89
  private legacyContext;
90
+ constructor(approvalRouter?: ApprovalRouter);
53
91
  setPromptFn(fn: (request: ApprovalRequest) => Promise<ApprovalResult>): void;
92
+ clearPromptFn(): void;
54
93
  /**
55
94
  * Has someone installed a real prompt callback? Engine consults this
56
95
  * to decide whether to use the interactive backend (= talk to a UI)
57
96
  * or fall back to HeadlessApprovalBackend (= deny-all). Without it,
58
- * the agent-server-stdio entry which wires setInteractiveApprovalFn
59
- * during server boot would still fall through to deny-all because
97
+ * a protocol host registers its connection with ApprovalRouter during boot
98
+ * but before the first session is bound without this check Engine would
99
+ * still fall through to deny-all because
60
100
  * the engine couldn't tell the difference. */
61
101
  hasPromptFn(): boolean;
62
102
  /** Inject the project root so persistence writes to the right settings file. */
@@ -107,10 +147,10 @@ export declare function pathRuleArgsPattern(absPath: string, scope: "file" | "di
107
147
  * JSON round-trip where a persisted RegExp comes back as a string.
108
148
  */
109
149
  export declare function ruleMatches(rule: PermissionRule, toolName: string, args: Record<string, unknown>): boolean;
150
+ export declare function getApprovalRouter(): ApprovalRouter;
110
151
  export declare function getInteractiveApprovalBackend(): InteractiveApprovalBackend;
111
152
  export declare function openInteractiveApprovalSession(sessionId: string): void;
112
153
  export declare function clearInteractiveApprovalSession(sessionId: string): void;
113
- export declare function setInteractiveApprovalFn(fn: (request: ApprovalRequest) => Promise<ApprovalResult>): void;
114
154
  type BashSafetyLevel = "safe-read" | "safe-write" | "unsafe" | "dangerous";
115
155
  /**
116
156
  * True when an otherwise-safe-read shell segment must be downgraded to `unsafe`