@probelabs/probe 0.6.0-rc225 → 0.6.0-rc227

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 (30) hide show
  1. package/bin/binaries/probe-v0.6.0-rc227-aarch64-apple-darwin.tar.gz +0 -0
  2. package/bin/binaries/probe-v0.6.0-rc227-aarch64-unknown-linux-musl.tar.gz +0 -0
  3. package/bin/binaries/probe-v0.6.0-rc227-x86_64-apple-darwin.tar.gz +0 -0
  4. package/bin/binaries/probe-v0.6.0-rc227-x86_64-pc-windows-msvc.zip +0 -0
  5. package/bin/binaries/probe-v0.6.0-rc227-x86_64-unknown-linux-musl.tar.gz +0 -0
  6. package/build/agent/ProbeAgent.d.ts +24 -0
  7. package/build/agent/ProbeAgent.js +310 -141
  8. package/build/agent/engines/enhanced-claude-code.js +72 -3
  9. package/build/agent/index.js +386 -129
  10. package/build/tools/analyzeAll.js +6 -1
  11. package/build/tools/bash.js +18 -3
  12. package/build/tools/edit.js +19 -10
  13. package/build/tools/vercel.js +17 -7
  14. package/build/utils/path-validation.js +148 -1
  15. package/cjs/agent/ProbeAgent.cjs +683 -389
  16. package/cjs/index.cjs +680 -389
  17. package/package.json +1 -1
  18. package/src/agent/ProbeAgent.d.ts +24 -0
  19. package/src/agent/ProbeAgent.js +310 -141
  20. package/src/agent/engines/enhanced-claude-code.js +72 -3
  21. package/src/tools/analyzeAll.js +6 -1
  22. package/src/tools/bash.js +18 -3
  23. package/src/tools/edit.js +19 -10
  24. package/src/tools/vercel.js +17 -7
  25. package/src/utils/path-validation.js +148 -1
  26. package/bin/binaries/probe-v0.6.0-rc225-aarch64-apple-darwin.tar.gz +0 -0
  27. package/bin/binaries/probe-v0.6.0-rc225-aarch64-unknown-linux-musl.tar.gz +0 -0
  28. package/bin/binaries/probe-v0.6.0-rc225-x86_64-apple-darwin.tar.gz +0 -0
  29. package/bin/binaries/probe-v0.6.0-rc225-x86_64-pc-windows-msvc.zip +0 -0
  30. package/bin/binaries/probe-v0.6.0-rc225-x86_64-unknown-linux-musl.tar.gz +0 -0
@@ -4,6 +4,29 @@
4
4
  import dotenv from 'dotenv';
5
5
  dotenv.config();
6
6
 
7
+ // ============================================================================
8
+ // Timeout Configuration Constants
9
+ // ============================================================================
10
+
11
+ /**
12
+ * Default activity timeout for engine streams (3 minutes).
13
+ * This is the time allowed between stream chunks before considering the stream stalled.
14
+ * Conservative default to handle extended thinking models that may not stream during thinking.
15
+ */
16
+ export const ENGINE_ACTIVITY_TIMEOUT_DEFAULT = 180000;
17
+
18
+ /**
19
+ * Minimum allowed activity timeout (5 seconds).
20
+ * Prevents unreasonably short timeouts that could cause premature failures.
21
+ */
22
+ export const ENGINE_ACTIVITY_TIMEOUT_MIN = 5000;
23
+
24
+ /**
25
+ * Maximum allowed activity timeout (10 minutes).
26
+ * Prevents excessively long waits for stalled streams.
27
+ */
28
+ export const ENGINE_ACTIVITY_TIMEOUT_MAX = 600000;
29
+
7
30
  import { createAnthropic } from '@ai-sdk/anthropic';
8
31
  import { createOpenAI } from '@ai-sdk/openai';
9
32
  import { createGoogleGenerativeAI } from '@ai-sdk/google';
@@ -71,6 +94,7 @@ import { RetryManager, createRetryManagerFromEnv } from './RetryManager.js';
71
94
  import { FallbackManager, createFallbackManagerFromEnv, buildFallbackProvidersFromEnv } from './FallbackManager.js';
72
95
  import { handleContextLimitError } from './contextCompactor.js';
73
96
  import { formatErrorForAI, ParameterError } from '../utils/error-types.js';
97
+ import { getCommonPrefix, toRelativePath, safeRealpath } from '../utils/path-validation.js';
74
98
  import { truncateIfNeeded, getMaxOutputTokens } from './outputTruncator.js';
75
99
  import { DelegationManager } from '../delegate.js';
76
100
  import {
@@ -188,6 +212,8 @@ export class ProbeAgent {
188
212
  * @param {number} [options.fallback.maxTotalAttempts=10] - Maximum total attempts across all providers
189
213
  * @param {string} [options.completionPrompt] - Custom prompt to run after attempt_completion for validation/review (runs before mermaid/JSON validation)
190
214
  * @param {number} [options.maxOutputTokens] - Maximum tokens for tool output before truncation (default: 20000, can also be set via PROBE_MAX_OUTPUT_TOKENS env var)
215
+ * @param {number} [options.requestTimeout] - Timeout in ms for AI requests (default: 120000 or REQUEST_TIMEOUT env var). Used to abort hung requests.
216
+ * @param {number} [options.maxOperationTimeout] - Maximum timeout in ms for the entire operation including all retries and fallbacks (default: 300000 or MAX_OPERATION_TIMEOUT env var). This is the absolute maximum time for streamTextWithRetryAndFallback.
191
217
  */
192
218
  constructor(options = {}) {
193
219
  // Basic configuration
@@ -269,8 +295,15 @@ export class ProbeAgent {
269
295
  this.allowedFolders = [process.cwd()];
270
296
  }
271
297
 
272
- // Working directory for resolving relative paths (separate from allowedFolders security)
273
- this.cwd = options.cwd || null;
298
+ // Compute workspace root as common prefix of all allowed folders
299
+ // This provides a single "root" for relative path resolution and default cwd
300
+ // IMPORTANT: workspaceRoot is NOT a security boundary - all security checks
301
+ // must be performed against this.allowedFolders, not workspaceRoot
302
+ this.workspaceRoot = getCommonPrefix(this.allowedFolders);
303
+
304
+ // Working directory for resolving relative paths
305
+ // If not explicitly provided, use workspace root for consistency
306
+ this.cwd = options.cwd || this.workspaceRoot;
274
307
 
275
308
  // API configuration
276
309
  this.clientApiProvider = options.provider || null;
@@ -289,6 +322,8 @@ export class ProbeAgent {
289
322
  console.log(`[DEBUG] Maximum tool iterations configured: ${MAX_TOOL_ITERATIONS}`);
290
323
  console.log(`[DEBUG] Allow Edit (implement tool): ${this.allowEdit}`);
291
324
  console.log(`[DEBUG] Search delegation enabled: ${this.searchDelegate}`);
325
+ console.log(`[DEBUG] Workspace root: ${this.workspaceRoot}`);
326
+ console.log(`[DEBUG] Working directory (cwd): ${this.cwd}`);
292
327
  }
293
328
 
294
329
  // Initialize tools
@@ -320,6 +355,41 @@ export class ProbeAgent {
320
355
  // Each ProbeAgent instance has its own limits, not shared globally
321
356
  this.delegationManager = new DelegationManager();
322
357
 
358
+ // Request timeout configuration (default 2 minutes)
359
+ // Validates env var to prevent NaN or unreasonable values
360
+ this.requestTimeout = options.requestTimeout ?? (() => {
361
+ if (process.env.REQUEST_TIMEOUT) {
362
+ const parsed = parseInt(process.env.REQUEST_TIMEOUT, 10);
363
+ // Validate: must be positive number between 1s and 1 hour
364
+ if (isNaN(parsed) || parsed < 1000 || parsed > 3600000) {
365
+ return 120000; // Default 2 minutes
366
+ }
367
+ return parsed;
368
+ }
369
+ return 120000;
370
+ })();
371
+ if (this.debug) {
372
+ console.log(`[DEBUG] Request timeout: ${this.requestTimeout}ms`);
373
+ }
374
+
375
+ // Maximum operation timeout for entire streamTextWithRetryAndFallback operation (default 5 minutes)
376
+ // This is the absolute maximum time including all retries and fallbacks
377
+ // Validates env var to prevent NaN or unreasonable values
378
+ this.maxOperationTimeout = options.maxOperationTimeout ?? (() => {
379
+ if (process.env.MAX_OPERATION_TIMEOUT) {
380
+ const parsed = parseInt(process.env.MAX_OPERATION_TIMEOUT, 10);
381
+ // Validate: must be positive number between 1s and 2 hours
382
+ if (isNaN(parsed) || parsed < 1000 || parsed > 7200000) {
383
+ return 300000; // Default 5 minutes
384
+ }
385
+ return parsed;
386
+ }
387
+ return 300000;
388
+ })();
389
+ if (this.debug) {
390
+ console.log(`[DEBUG] Max operation timeout: ${this.maxOperationTimeout}ms`);
391
+ }
392
+
323
393
  // Retry configuration
324
394
  this.retryConfig = options.retry || {};
325
395
  this.retryManager = null; // Will be initialized lazily when needed
@@ -732,8 +802,9 @@ export class ProbeAgent {
732
802
  const configOptions = {
733
803
  sessionId: this.sessionId,
734
804
  debug: this.debug,
735
- // Use explicit cwd if set, otherwise fall back to first allowed folder
736
- cwd: this.cwd || (this.allowedFolders.length > 0 ? this.allowedFolders[0] : process.cwd()),
805
+ // Use cwd (which defaults to workspaceRoot in constructor)
806
+ cwd: this.cwd,
807
+ workspaceRoot: this.workspaceRoot,
737
808
  allowedFolders: this.allowedFolders,
738
809
  outline: this.outline,
739
810
  searchDelegate: this.searchDelegate,
@@ -1094,120 +1165,128 @@ export class ProbeAgent {
1094
1165
  }
1095
1166
 
1096
1167
  /**
1097
- * Execute streamText with retry and fallback support
1098
- * @param {Object} options - streamText options
1099
- * @returns {Promise<Object>} - streamText result
1168
+ * Create a streamText-compatible result from an engine stream with timeout handling
1169
+ * @param {AsyncGenerator} engineStream - The engine's query result
1170
+ * @param {AbortSignal} abortSignal - Signal for aborting the operation
1171
+ * @param {number} requestTimeout - Per-request timeout in ms
1172
+ * @param {Object} timeoutState - Object with timeoutId property (mutable for cleanup)
1173
+ * @returns {Object} - streamText-compatible result with textStream
1100
1174
  * @private
1101
1175
  */
1102
- async streamTextWithRetryAndFallback(options) {
1103
- // Check if we should use Claude Code engine
1104
- if (this.clientApiProvider === 'claude-code' || process.env.USE_CLAUDE_CODE === 'true') {
1176
+ _createEngineTextStreamResult(engineStream, abortSignal, requestTimeout, timeoutState) {
1177
+ // Activity timeout for engine stream - validates env var against defined bounds
1178
+ const activityTimeout = (() => {
1179
+ const parsed = parseInt(process.env.ENGINE_ACTIVITY_TIMEOUT, 10);
1180
+ return isNaN(parsed) || parsed < ENGINE_ACTIVITY_TIMEOUT_MIN || parsed > ENGINE_ACTIVITY_TIMEOUT_MAX
1181
+ ? ENGINE_ACTIVITY_TIMEOUT_DEFAULT
1182
+ : parsed;
1183
+ })();
1184
+ const startTime = Date.now();
1185
+
1186
+ // Create a text stream that extracts text from engine messages with timeout
1187
+ // The generator clears the operation timeout when done to handle the case
1188
+ // where the stream is returned immediately but consumed later
1189
+ async function* createTextStream() {
1190
+ let lastActivity = Date.now();
1191
+
1105
1192
  try {
1106
- const engine = await this.getEngine();
1107
- if (engine && engine.query) {
1108
- // Convert Vercel AI SDK format to engine format
1109
- // Extract the ORIGINAL user message as the main prompt (skip any warning messages)
1110
- // Look for user messages that are NOT the warning message
1111
- const userMessages = options.messages.filter(m =>
1112
- m.role === 'user' &&
1113
- !m.content.includes('WARNING: You have reached the maximum tool iterations limit')
1114
- );
1115
- const lastUserMessage = userMessages[userMessages.length - 1];
1116
- const prompt = lastUserMessage ? lastUserMessage.content : '';
1117
-
1118
- // Pass system message and other options
1119
- const engineOptions = {
1120
- maxTokens: options.maxTokens,
1121
- temperature: options.temperature,
1122
- messages: options.messages,
1123
- systemPrompt: options.messages.find(m => m.role === 'system')?.content
1124
- };
1125
-
1126
- // Get the engine's query result (async generator)
1127
- const engineStream = engine.query(prompt, engineOptions);
1128
-
1129
- // Create a text stream that extracts text from engine messages
1130
- async function* createTextStream() {
1131
- for await (const message of engineStream) {
1132
- if (message.type === 'text' && message.content) {
1133
- yield message.content;
1134
- } else if (typeof message === 'string') {
1135
- // If engine returns plain strings, pass them through
1136
- yield message;
1137
- }
1138
- // Ignore other message types for the text stream
1139
- }
1193
+ for await (const message of engineStream) {
1194
+ // Check for abort signal
1195
+ if (abortSignal.aborted) {
1196
+ const abortError = new Error('Operation aborted');
1197
+ abortError.name = 'AbortError';
1198
+ throw abortError;
1140
1199
  }
1141
1200
 
1142
- // Wrap the engine result to match streamText interface
1143
- return {
1144
- textStream: createTextStream(),
1145
- usage: Promise.resolve({}), // Engine should handle its own usage tracking
1146
- // Add other streamText-compatible properties as needed
1147
- };
1148
- }
1149
- } catch (error) {
1150
- if (this.debug) {
1151
- console.log(`[DEBUG] Failed to use Claude Code engine, falling back to Vercel:`, error.message);
1152
- }
1153
- // Fall through to use Vercel engine as fallback
1154
- }
1155
- }
1201
+ const now = Date.now();
1156
1202
 
1157
- // Check if we should use Codex engine
1158
- if (this.clientApiProvider === 'codex' || process.env.USE_CODEX === 'true') {
1159
- try {
1160
- const engine = await this.getEngine();
1161
- if (engine && engine.query) {
1162
- // Convert Vercel AI SDK format to engine format
1163
- // Extract the ORIGINAL user message as the main prompt (skip any warning messages)
1164
- // Look for user messages that are NOT the warning message
1165
- const userMessages = options.messages.filter(m =>
1166
- m.role === 'user' &&
1167
- !m.content.includes('WARNING: You have reached the maximum tool iterations limit')
1168
- );
1169
- const lastUserMessage = userMessages[userMessages.length - 1];
1170
- const prompt = lastUserMessage ? lastUserMessage.content : '';
1171
-
1172
- // Pass system message and other options
1173
- const engineOptions = {
1174
- maxTokens: options.maxTokens,
1175
- temperature: options.temperature,
1176
- messages: options.messages,
1177
- systemPrompt: options.messages.find(m => m.role === 'system')?.content
1178
- };
1179
-
1180
- // Get the engine's query result (async generator)
1181
- const engineStream = engine.query(prompt, engineOptions);
1182
-
1183
- // Create a text stream that extracts text from engine messages
1184
- async function* createTextStream() {
1185
- for await (const message of engineStream) {
1186
- if (message.type === 'text' && message.content) {
1187
- yield message.content;
1188
- } else if (typeof message === 'string') {
1189
- // If engine returns plain strings, pass them through
1190
- yield message;
1191
- }
1192
- // Ignore other message types for the text stream
1193
- }
1203
+ // Check for activity timeout (no data received for too long)
1204
+ if (now - lastActivity > activityTimeout) {
1205
+ throw new Error(`Engine stream timeout - no activity for ${activityTimeout}ms`);
1206
+ }
1207
+
1208
+ // Check for overall request timeout
1209
+ if (requestTimeout > 0 && now - startTime > requestTimeout) {
1210
+ throw new Error(`Engine stream timeout - request exceeded ${requestTimeout}ms`);
1194
1211
  }
1195
1212
 
1196
- // Wrap the engine result to match streamText interface
1197
- return {
1198
- textStream: createTextStream(),
1199
- usage: Promise.resolve({}), // Engine should handle its own usage tracking
1200
- // Add other streamText-compatible properties as needed
1201
- };
1213
+ lastActivity = now;
1214
+
1215
+ if (message.type === 'text' && message.content) {
1216
+ yield message.content;
1217
+ } else if (typeof message === 'string') {
1218
+ // If engine returns plain strings, pass them through
1219
+ yield message;
1220
+ }
1221
+ // Ignore other message types for the text stream
1202
1222
  }
1203
- } catch (error) {
1204
- if (this.debug) {
1205
- console.log(`[DEBUG] Failed to use Codex engine, falling back to Vercel:`, error.message);
1223
+ } finally {
1224
+ // Clear operation timeout when stream completes (success or error)
1225
+ // This is done here because for engine paths, the stream is returned
1226
+ // immediately but consumed later by the caller
1227
+ if (timeoutState.timeoutId) {
1228
+ clearTimeout(timeoutState.timeoutId);
1229
+ timeoutState.timeoutId = null;
1206
1230
  }
1207
- // Fall through to use Vercel engine as fallback
1208
1231
  }
1209
1232
  }
1210
1233
 
1234
+ // Wrap the engine result to match streamText interface
1235
+ // Note: maxOperationTimeout cleanup is handled by the generator's finally block
1236
+ // since the stream is consumed after this function returns.
1237
+ return {
1238
+ textStream: createTextStream(),
1239
+ usage: Promise.resolve({}), // Engine should handle its own usage tracking
1240
+ // Add other streamText-compatible properties as needed
1241
+ };
1242
+ }
1243
+
1244
+ /**
1245
+ * Try to use an engine (claude-code or codex) for streaming
1246
+ * @param {Object} options - streamText options
1247
+ * @param {AbortController} controller - Abort controller for the operation
1248
+ * @param {Object} timeoutState - Mutable timeout state for cleanup
1249
+ * @returns {Promise<Object|null>} - Stream result or null if engine unavailable
1250
+ * @private
1251
+ */
1252
+ async _tryEngineStreamPath(options, controller, timeoutState) {
1253
+ const engine = await this.getEngine();
1254
+ if (!engine || !engine.query) {
1255
+ return null;
1256
+ }
1257
+
1258
+ // Extract the ORIGINAL user message as the main prompt (skip any warning messages)
1259
+ const userMessages = options.messages.filter(m =>
1260
+ m.role === 'user' &&
1261
+ !m.content.includes('WARNING: You have reached the maximum tool iterations limit')
1262
+ );
1263
+ const lastUserMessage = userMessages[userMessages.length - 1];
1264
+ const prompt = lastUserMessage ? lastUserMessage.content : '';
1265
+
1266
+ // Pass system message and other options including abort signal
1267
+ const engineOptions = {
1268
+ maxTokens: options.maxTokens,
1269
+ temperature: options.temperature,
1270
+ messages: options.messages,
1271
+ systemPrompt: options.messages.find(m => m.role === 'system')?.content,
1272
+ abortSignal: controller.signal
1273
+ };
1274
+
1275
+ // Get the engine's query result and wrap with timeout handling
1276
+ const engineStream = engine.query(prompt, engineOptions);
1277
+ return this._createEngineTextStreamResult(
1278
+ engineStream, controller.signal, this.requestTimeout, timeoutState
1279
+ );
1280
+ }
1281
+
1282
+ /**
1283
+ * Execute streamText with Vercel AI SDK using retry/fallback logic
1284
+ * @param {Object} options - streamText options
1285
+ * @param {AbortController} controller - Abort controller for the operation
1286
+ * @returns {Promise<Object>} - Stream result
1287
+ * @private
1288
+ */
1289
+ async _executeWithVercelProvider(options, controller) {
1211
1290
  // Initialize retry manager if not already created
1212
1291
  if (!this.retryManager) {
1213
1292
  this.retryManager = new RetryManager({
@@ -1223,10 +1302,11 @@ export class ProbeAgent {
1223
1302
  // If no fallback manager, just use retry with current provider
1224
1303
  if (!this.fallbackManager) {
1225
1304
  return await this.retryManager.executeWithRetry(
1226
- () => streamText(options),
1305
+ () => streamText({ ...options, abortSignal: controller.signal }),
1227
1306
  {
1228
1307
  provider: this.apiType,
1229
- model: this.model
1308
+ model: this.model,
1309
+ signal: controller.signal
1230
1310
  }
1231
1311
  );
1232
1312
  }
@@ -1234,13 +1314,12 @@ export class ProbeAgent {
1234
1314
  // Use fallback manager with retry for each provider
1235
1315
  return await this.fallbackManager.executeWithFallback(
1236
1316
  async (provider, model, config) => {
1237
- // Create options with the fallback provider
1238
1317
  const fallbackOptions = {
1239
1318
  ...options,
1240
- model: provider(model)
1319
+ model: provider(model),
1320
+ abortSignal: controller.signal
1241
1321
  };
1242
1322
 
1243
- // Create a retry manager for this specific provider
1244
1323
  const providerRetryManager = new RetryManager({
1245
1324
  maxRetries: config.maxRetries ?? this.retryConfig.maxRetries ?? 3,
1246
1325
  initialDelay: this.retryConfig.initialDelay ?? 1000,
@@ -1250,18 +1329,70 @@ export class ProbeAgent {
1250
1329
  debug: this.debug
1251
1330
  });
1252
1331
 
1253
- // Execute with retry for this provider
1254
1332
  return await providerRetryManager.executeWithRetry(
1255
1333
  () => streamText(fallbackOptions),
1256
1334
  {
1257
1335
  provider: config.provider,
1258
- model: model
1336
+ model: model,
1337
+ signal: controller.signal
1259
1338
  }
1260
1339
  );
1261
1340
  }
1262
1341
  );
1263
1342
  }
1264
1343
 
1344
+ /**
1345
+ * Execute streamText with retry and fallback support
1346
+ * @param {Object} options - streamText options
1347
+ * @returns {Promise<Object>} - streamText result
1348
+ * @private
1349
+ */
1350
+ async streamTextWithRetryAndFallback(options) {
1351
+ // Create AbortController for overall operation timeout
1352
+ const controller = new AbortController();
1353
+ const timeoutState = { timeoutId: null };
1354
+
1355
+ // Set up overall operation timeout (default 5 minutes)
1356
+ if (this.maxOperationTimeout && this.maxOperationTimeout > 0) {
1357
+ timeoutState.timeoutId = setTimeout(() => {
1358
+ controller.abort();
1359
+ if (this.debug) {
1360
+ console.log(`[DEBUG] Operation timed out after ${this.maxOperationTimeout}ms (max operation timeout)`);
1361
+ }
1362
+ }, this.maxOperationTimeout);
1363
+ }
1364
+
1365
+ try {
1366
+ // Try engine paths (claude-code or codex)
1367
+ const useClaudeCode = this.clientApiProvider === 'claude-code' || process.env.USE_CLAUDE_CODE === 'true';
1368
+ const useCodex = this.clientApiProvider === 'codex' || process.env.USE_CODEX === 'true';
1369
+
1370
+ if (useClaudeCode || useCodex) {
1371
+ try {
1372
+ const result = await this._tryEngineStreamPath(options, controller, timeoutState);
1373
+ if (result) {
1374
+ return result;
1375
+ }
1376
+ } catch (error) {
1377
+ if (this.debug) {
1378
+ const engineType = useClaudeCode ? 'Claude Code' : 'Codex';
1379
+ console.log(`[DEBUG] Failed to use ${engineType} engine, falling back to Vercel:`, error.message);
1380
+ }
1381
+ // Fall through to Vercel provider
1382
+ }
1383
+ }
1384
+
1385
+ // Use Vercel AI SDK with retry/fallback
1386
+ return await this._executeWithVercelProvider(options, controller);
1387
+ } finally {
1388
+ // Clean up timeout (for non-engine paths; engine paths clean up in the generator)
1389
+ if (timeoutState.timeoutId) {
1390
+ clearTimeout(timeoutState.timeoutId);
1391
+ timeoutState.timeoutId = null;
1392
+ }
1393
+ }
1394
+ }
1395
+
1265
1396
  /**
1266
1397
  * Initialize Anthropic model
1267
1398
  */
@@ -1612,7 +1743,8 @@ export class ProbeAgent {
1612
1743
  }
1613
1744
 
1614
1745
  // Security validation: check if path is within any allowed directory
1615
- // Use normalize() after resolve() to handle path traversal attempts (e.g., '/allowed/../etc/passwd')
1746
+ // Use safeRealpath() to resolve symlinks and handle path traversal attempts (e.g., '/allowed/../etc/passwd')
1747
+ // This prevents symlink bypass attacks (e.g., /tmp -> /private/tmp on macOS)
1616
1748
  const allowedDirs = this.allowedFolders && this.allowedFolders.length > 0 ? this.allowedFolders : [process.cwd()];
1617
1749
 
1618
1750
  let absolutePath;
@@ -1620,20 +1752,20 @@ export class ProbeAgent {
1620
1752
 
1621
1753
  // If absolute path, check if it's within any allowed directory
1622
1754
  if (isAbsolute(imagePath)) {
1623
- // Normalize to resolve any '..' sequences
1624
- absolutePath = normalize(resolve(imagePath));
1755
+ // Use safeRealpath to resolve symlinks for security
1756
+ absolutePath = safeRealpath(resolve(imagePath));
1625
1757
  isPathAllowed = allowedDirs.some(dir => {
1626
- const normalizedDir = normalize(resolve(dir));
1758
+ const resolvedDir = safeRealpath(dir);
1627
1759
  // Ensure the path is within the allowed directory (add separator to prevent prefix attacks)
1628
- return absolutePath === normalizedDir || absolutePath.startsWith(normalizedDir + sep);
1760
+ return absolutePath === resolvedDir || absolutePath.startsWith(resolvedDir + sep);
1629
1761
  });
1630
1762
  } else {
1631
1763
  // For relative paths, try resolving against each allowed directory
1632
1764
  for (const dir of allowedDirs) {
1633
- const normalizedDir = normalize(resolve(dir));
1634
- const resolvedPath = normalize(resolve(dir, imagePath));
1765
+ const resolvedDir = safeRealpath(dir);
1766
+ const resolvedPath = safeRealpath(resolve(dir, imagePath));
1635
1767
  // Ensure the resolved path is within the allowed directory
1636
- if (resolvedPath === normalizedDir || resolvedPath.startsWith(normalizedDir + sep)) {
1768
+ if (resolvedPath === resolvedDir || resolvedPath.startsWith(resolvedDir + sep)) {
1637
1769
  absolutePath = resolvedPath;
1638
1770
  isPathAllowed = true;
1639
1771
  break;
@@ -1870,7 +2002,8 @@ export class ProbeAgent {
1870
2002
  return this.architectureContext;
1871
2003
  }
1872
2004
 
1873
- const rootDirectory = this.allowedFolders.length > 0 ? this.allowedFolders[0] : process.cwd();
2005
+ // Use workspaceRoot for consistent path handling
2006
+ const rootDirectory = this.workspaceRoot || (this.allowedFolders.length > 0 ? this.allowedFolders[0] : process.cwd());
1874
2007
  const configuredName =
1875
2008
  typeof this.architectureFileName === 'string' ? this.architectureFileName.trim() : '';
1876
2009
  const hasConfiguredName = !!configuredName;
@@ -2028,6 +2161,10 @@ export class ProbeAgent {
2028
2161
  }
2029
2162
 
2030
2163
  _getSkillsRepoRoot() {
2164
+ // Use workspaceRoot for consistent path handling
2165
+ if (this.workspaceRoot) {
2166
+ return resolve(this.workspaceRoot);
2167
+ }
2031
2168
  if (this.allowedFolders && this.allowedFolders.length > 0) {
2032
2169
  return resolve(this.allowedFolders[0]);
2033
2170
  }
@@ -2108,7 +2245,7 @@ ${extractGuidance}
2108
2245
  // Add repository structure if available
2109
2246
  if (this.fileList) {
2110
2247
  systemPrompt += `\n\n# Repository Structure\n`;
2111
- systemPrompt += `You are working with a repository located at: ${this.allowedFolders[0]}\n\n`;
2248
+ systemPrompt += `You are working with a repository located at: ${this.workspaceRoot}\n\n`;
2112
2249
  systemPrompt += `Here's an overview of the repository structure (showing up to 100 most relevant files):\n\n`;
2113
2250
  systemPrompt += '```\n' + this.fileList + '\n```\n';
2114
2251
  }
@@ -2170,7 +2307,7 @@ ${extractGuidance}
2170
2307
  // Add repository structure if available
2171
2308
  if (this.fileList) {
2172
2309
  systemPrompt += `\n\n# Repository Structure\n`;
2173
- systemPrompt += `You are working with a repository located at: ${this.allowedFolders[0]}\n\n`;
2310
+ systemPrompt += `You are working with a repository located at: ${this.workspaceRoot}\n\n`;
2174
2311
  systemPrompt += `Here's an overview of the repository structure (showing up to 100 most relevant files):\n\n`;
2175
2312
  systemPrompt += '```\n' + this.fileList + '\n```\n';
2176
2313
  }
@@ -2484,10 +2621,29 @@ Follow these instructions carefully:
2484
2621
  }
2485
2622
  }
2486
2623
 
2487
- // Add folder information
2488
- const searchDirectory = this.allowedFolders.length > 0 ? this.allowedFolders[0] : process.cwd();
2624
+ // Add folder information using workspace root and relative paths
2625
+ const searchDirectory = this.workspaceRoot;
2489
2626
  if (this.debug) {
2490
- console.log(`[DEBUG] Generating file list for base directory: ${searchDirectory}...`);
2627
+ console.log(`[DEBUG] Generating file list for workspace root: ${searchDirectory}...`);
2628
+ }
2629
+
2630
+ // Convert allowed folders to relative paths for cleaner AI context
2631
+ // Add ./ prefix to make it clear these are relative paths
2632
+ const relativeWorkspaces = this.allowedFolders.map(f => {
2633
+ const rel = toRelativePath(f, this.workspaceRoot);
2634
+ // Add ./ prefix if not already starting with . and not an absolute path
2635
+ if (rel && rel !== '.' && !rel.startsWith('.') && !rel.startsWith('/')) {
2636
+ return './' + rel;
2637
+ }
2638
+ return rel;
2639
+ }).filter(f => f && f !== '.');
2640
+
2641
+ // Describe available paths in a user-friendly way
2642
+ let workspaceDesc;
2643
+ if (relativeWorkspaces.length === 0) {
2644
+ workspaceDesc = '. (current directory)';
2645
+ } else {
2646
+ workspaceDesc = relativeWorkspaces.join(', ');
2491
2647
  }
2492
2648
 
2493
2649
  try {
@@ -2495,15 +2651,15 @@ Follow these instructions carefully:
2495
2651
  directory: searchDirectory,
2496
2652
  maxFiles: 100,
2497
2653
  respectGitignore: !process.env.PROBE_NO_GITIGNORE || process.env.PROBE_NO_GITIGNORE === '',
2498
- cwd: process.cwd()
2654
+ cwd: this.workspaceRoot
2499
2655
  });
2500
2656
 
2501
- systemMessage += `\n# Repository Structure\n\nYou are working with a repository located at: ${searchDirectory}\n\nHere's an overview of the repository structure (showing up to 100 most relevant files):\n\n\`\`\`\n${files}\n\`\`\`\n\n`;
2657
+ systemMessage += `\n# Repository Structure\n\nYou are working with a workspace. Available paths: ${workspaceDesc}\n\nHere's an overview of the repository structure (showing up to 100 most relevant files):\n\n\`\`\`\n${files}\n\`\`\`\n\n`;
2502
2658
  } catch (error) {
2503
2659
  if (this.debug) {
2504
2660
  console.log(`[DEBUG] Could not generate file list: ${error.message}`);
2505
2661
  }
2506
- systemMessage += `\n# Repository Structure\n\nYou are working with a repository located at: ${searchDirectory}\n\n`;
2662
+ systemMessage += `\n# Repository Structure\n\nYou are working with a workspace. Available paths: ${workspaceDesc}\n\n`;
2507
2663
  }
2508
2664
 
2509
2665
  // Add architecture context if available
@@ -2511,7 +2667,15 @@ Follow these instructions carefully:
2511
2667
  systemMessage += this.getArchitectureSection();
2512
2668
 
2513
2669
  if (this.allowedFolders.length > 0) {
2514
- systemMessage += `\n**Important**: For security reasons, you can only search within these allowed folders: ${this.allowedFolders.join(', ')}\n\n`;
2670
+ const relativeAllowed = this.allowedFolders.map(f => {
2671
+ const rel = toRelativePath(f, this.workspaceRoot);
2672
+ // Add ./ prefix if not already starting with . and not an absolute path
2673
+ if (rel && rel !== '.' && !rel.startsWith('.') && !rel.startsWith('/')) {
2674
+ return './' + rel;
2675
+ }
2676
+ return rel;
2677
+ });
2678
+ systemMessage += `\n**Important**: For security reasons, you can only access these paths: ${relativeAllowed.join(', ')}\n\n`;
2515
2679
  }
2516
2680
 
2517
2681
  return systemMessage;
@@ -3234,6 +3398,8 @@ Follow these instructions carefully:
3234
3398
  console.error(`[DEBUG] ========================================\n`);
3235
3399
  }
3236
3400
 
3401
+ // Add assistant message with tool call (matching native tool pattern)
3402
+ currentMessages.push({ role: 'assistant', content: assistantResponseContent });
3237
3403
  currentMessages.push({ role: 'user', content: `<tool_result>\n${toolResultContent}\n</tool_result>` });
3238
3404
  } catch (error) {
3239
3405
  // Record MCP tool end event (failure)
@@ -3257,24 +3423,27 @@ Follow these instructions carefully:
3257
3423
 
3258
3424
  // Format error with structured information for AI
3259
3425
  const errorXml = formatErrorForAI(error);
3426
+ // Add assistant message with tool call (matching native tool pattern)
3427
+ currentMessages.push({ role: 'assistant', content: assistantResponseContent });
3260
3428
  currentMessages.push({ role: 'user', content: `<tool_result>\n${errorXml}\n</tool_result>` });
3261
3429
  }
3262
3430
  } else if (this.toolImplementations[toolName]) {
3263
3431
  // Execute native tool
3264
3432
  try {
3265
3433
  // Add sessionId and workingDirectory to params for tool execution
3266
- // Validate and resolve workingDirectory
3267
- // Priority: explicit cwd > first allowed folder > process.cwd()
3268
- let resolvedWorkingDirectory = this.cwd || (this.allowedFolders && this.allowedFolders[0]) || process.cwd();
3434
+ // Validate and resolve workingDirectory using safeRealpath for symlink security
3435
+ // Consistent fallback chain: workspaceRoot > cwd > allowedFolders[0] > process.cwd()
3436
+ let resolvedWorkingDirectory = this.workspaceRoot || this.cwd || (this.allowedFolders && this.allowedFolders[0]) || process.cwd();
3269
3437
  if (params.workingDirectory) {
3270
3438
  // Resolve relative paths against the current working directory context, not process.cwd()
3271
- const requestedDir = isAbsolute(params.workingDirectory)
3439
+ // Use safeRealpath to resolve symlinks and prevent bypass attacks
3440
+ const requestedDir = safeRealpath(isAbsolute(params.workingDirectory)
3272
3441
  ? resolve(params.workingDirectory)
3273
- : resolve(resolvedWorkingDirectory, params.workingDirectory);
3442
+ : resolve(resolvedWorkingDirectory, params.workingDirectory));
3274
3443
  // Check if the requested directory is within allowed folders
3275
3444
  const isWithinAllowed = !this.allowedFolders || this.allowedFolders.length === 0 ||
3276
3445
  this.allowedFolders.some(folder => {
3277
- const resolvedFolder = resolve(folder);
3446
+ const resolvedFolder = safeRealpath(folder);
3278
3447
  return requestedDir === resolvedFolder || requestedDir.startsWith(resolvedFolder + sep);
3279
3448
  });
3280
3449
  if (isWithinAllowed) {
@@ -3887,7 +4056,7 @@ Convert your previous response content into actual JSON data that follows this s
3887
4056
 
3888
4057
  const mermaidValidation = await validateAndFixMermaidResponse(finalResult, {
3889
4058
  debug: this.debug,
3890
- path: this.allowedFolders[0],
4059
+ path: this.workspaceRoot || this.allowedFolders[0],
3891
4060
  provider: this.clientApiProvider,
3892
4061
  model: this.model,
3893
4062
  tracer: this.tracer
@@ -3977,7 +4146,7 @@ Convert your previous response content into actual JSON data that follows this s
3977
4146
 
3978
4147
  const { JsonFixingAgent } = await import('./schemaUtils.js');
3979
4148
  const jsonFixer = new JsonFixingAgent({
3980
- path: this.allowedFolders[0],
4149
+ path: this.workspaceRoot || this.allowedFolders[0],
3981
4150
  provider: this.clientApiProvider,
3982
4151
  model: this.model,
3983
4152
  debug: this.debug,
@@ -4065,7 +4234,7 @@ Convert your previous response content into actual JSON data that follows this s
4065
4234
 
4066
4235
  const mermaidValidation = await validateAndFixMermaidResponse(finalResult, {
4067
4236
  debug: this.debug,
4068
- path: this.allowedFolders[0],
4237
+ path: this.workspaceRoot || this.allowedFolders[0],
4069
4238
  provider: this.clientApiProvider,
4070
4239
  model: this.model,
4071
4240
  tracer: this.tracer
@@ -4221,7 +4390,7 @@ Convert your previous response content into actual JSON data that follows this s
4221
4390
 
4222
4391
  const finalMermaidValidation = await validateAndFixMermaidResponse(finalResult, {
4223
4392
  debug: this.debug,
4224
- path: this.allowedFolders[0],
4393
+ path: this.workspaceRoot || this.allowedFolders[0],
4225
4394
  provider: this.clientApiProvider,
4226
4395
  model: this.model,
4227
4396
  tracer: this.tracer
@@ -4419,7 +4588,7 @@ Convert your previous response content into actual JSON data that follows this s
4419
4588
  allowEdit: this.allowEdit,
4420
4589
  enableDelegate: this.enableDelegate,
4421
4590
  architectureFileName: this.architectureFileName,
4422
- path: this.allowedFolders[0], // Use first allowed folder as primary path
4591
+ // Pass allowedFolders which will recompute workspaceRoot correctly
4423
4592
  allowedFolders: [...this.allowedFolders],
4424
4593
  cwd: this.cwd, // Preserve explicit working directory
4425
4594
  provider: this.clientApiProvider,