@octocodeai/octocode-engine 16.6.0 → 16.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +190 -249
- package/dist/lsp/client.d.ts +18 -9
- package/dist/lsp/client.js +35 -25
- package/dist/lsp/config.d.ts +29 -1
- package/dist/lsp/config.js +156 -1
- package/dist/lsp/ideContext.d.ts +18 -0
- package/dist/lsp/ideContext.js +29 -0
- package/dist/lsp/index.d.ts +8 -3
- package/dist/lsp/index.js +7 -2
- package/dist/lsp/lspClientPool.d.ts +2 -0
- package/dist/lsp/lspClientPool.js +9 -4
- package/dist/lsp/manager.d.ts +11 -0
- package/dist/lsp/manager.js +72 -25
- package/dist/lsp/native.d.ts +2 -1
- package/dist/lsp/platform.d.ts +20 -0
- package/dist/lsp/platform.js +64 -0
- package/dist/lsp/resolver.js +10 -4
- package/dist/lsp/serverDiscovery.d.ts +63 -0
- package/dist/lsp/serverDiscovery.js +226 -0
- package/dist/lsp/serverManifest.d.ts +71 -0
- package/dist/lsp/serverManifest.js +116 -0
- package/dist/lsp/serverManifestData.d.ts +2 -0
- package/dist/lsp/serverManifestData.js +96 -0
- package/dist/lsp/serverProvisioner.d.ts +19 -0
- package/dist/lsp/serverProvisioner.js +262 -0
- package/dist/lsp/types.d.ts +18 -0
- package/dist/security/commandValidator.js +74 -10
- package/dist/security/contentSanitizer.js +4 -3
- package/dist/security/discoveryFilter.js +10 -0
- package/dist/security/filePatterns.js +6 -0
- package/dist/security/ignoredPathFilter.js +10 -2
- package/dist/security/mask.js +5 -22
- package/dist/security/maskUtils.d.ts +6 -0
- package/dist/security/maskUtils.js +22 -0
- package/dist/security/native.js +5 -22
- package/dist/security/pathPatterns.js +5 -0
- package/dist/security/pathValidator.js +38 -37
- package/dist/security/registry.js +27 -9
- package/dist/security/securityConstants.d.ts +1 -1
- package/dist/security/securityConstants.js +22 -1
- package/dist/security/withSecurityValidation.js +18 -3
- package/docs/LSP_SERVER_LIFECYCLE.md +258 -0
- package/index.d.ts +8 -2
- package/package.json +54 -10
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
// File-access block list: prevents reading sensitive files by name/extension.
|
|
2
|
+
// SYNC NOTE: discoveryFilter.ts:DISCOVERY_IGNORED_FILE_NAMES and
|
|
3
|
+
// DISCOVERY_IGNORED_FILE_EXTENSIONS partially overlap this list (e.g. id_rsa,
|
|
4
|
+
// id_dsa, .pem, .key, .crt). Both sets must be kept in sync — a new sensitive
|
|
5
|
+
// file added here should also be added to discoveryFilter.ts to prevent it
|
|
6
|
+
// from appearing in discovery results, and vice versa.
|
|
1
7
|
export const IGNORED_FILE_PATTERNS = [
|
|
2
8
|
/\.env$/,
|
|
3
9
|
/\.env\..+$/,
|
|
@@ -22,7 +22,10 @@ function getCompiledPathRegex() {
|
|
|
22
22
|
const all = extra.length > 0
|
|
23
23
|
? [...IGNORED_PATH_PATTERNS, ...extra]
|
|
24
24
|
: IGNORED_PATH_PATTERNS;
|
|
25
|
-
|
|
25
|
+
// Wrap each source in a non-capturing group so alternation precedence is
|
|
26
|
+
// correct: `a|b` joined with `c|d` must be `(?:a|b)|(?:c|d)`, not
|
|
27
|
+
// `a|b|c|d` which re-associates differently once combined.
|
|
28
|
+
_compiledPathRegex = new RegExp(all.map(r => `(?:${stripNamedGroups(r.source)})`).join('|'));
|
|
26
29
|
}
|
|
27
30
|
return _compiledPathRegex;
|
|
28
31
|
}
|
|
@@ -33,7 +36,7 @@ function getCompiledFileRegex() {
|
|
|
33
36
|
const all = extra.length > 0
|
|
34
37
|
? [...IGNORED_FILE_PATTERNS, ...extra]
|
|
35
38
|
: IGNORED_FILE_PATTERNS;
|
|
36
|
-
_compiledFileRegex = new RegExp(all.map(r => stripNamedGroups(r.source)).join('|'));
|
|
39
|
+
_compiledFileRegex = new RegExp(all.map(r => `(?:${stripNamedGroups(r.source)})`).join('|'));
|
|
37
40
|
}
|
|
38
41
|
return _compiledFileRegex;
|
|
39
42
|
}
|
|
@@ -51,6 +54,11 @@ export function shouldIgnorePath(pathToCheck) {
|
|
|
51
54
|
return regex.test(normalizedPath);
|
|
52
55
|
}
|
|
53
56
|
function normalizePathForIgnoreMatching(normalizedPath) {
|
|
57
|
+
if (normalizedPath === '/private/tmp')
|
|
58
|
+
return '/tmp';
|
|
59
|
+
if (normalizedPath.startsWith('/private/tmp/')) {
|
|
60
|
+
return normalizedPath.slice('/private'.length);
|
|
61
|
+
}
|
|
54
62
|
if (normalizedPath === '/private/var')
|
|
55
63
|
return '/var';
|
|
56
64
|
if (normalizedPath.startsWith('/private/var/')) {
|
package/dist/security/mask.js
CHANGED
|
@@ -1,41 +1,24 @@
|
|
|
1
1
|
import { nativeMaskSensitiveData } from './native.js';
|
|
2
2
|
import { securityRegistry } from './registry.js';
|
|
3
|
-
import {
|
|
3
|
+
import { deduplicateSpans, applyMaskToSpans } from './maskUtils.js';
|
|
4
4
|
function applyJsMask(text, patterns) {
|
|
5
5
|
const applicable = patterns.filter(p => !p.fileContext);
|
|
6
6
|
if (applicable.length === 0)
|
|
7
7
|
return text;
|
|
8
|
-
const
|
|
8
|
+
const spans = [];
|
|
9
9
|
for (const p of applicable) {
|
|
10
10
|
p.regex.lastIndex = 0;
|
|
11
11
|
let m;
|
|
12
12
|
const re = new RegExp(p.regex.source, p.regex.flags.replace('g', '') + 'g');
|
|
13
13
|
while ((m = re.exec(text)) !== null) {
|
|
14
|
-
|
|
14
|
+
spans.push({ start: m.index, end: m.index + m[0].length });
|
|
15
15
|
if (m[0].length === 0)
|
|
16
16
|
re.lastIndex++;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
-
if (
|
|
19
|
+
if (spans.length === 0)
|
|
20
20
|
return text;
|
|
21
|
-
|
|
22
|
-
const deduped = [];
|
|
23
|
-
let lastEnd = 0;
|
|
24
|
-
for (const m of matches) {
|
|
25
|
-
if (m.start >= lastEnd) {
|
|
26
|
-
deduped.push(m);
|
|
27
|
-
lastEnd = m.end;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
let result = text;
|
|
31
|
-
for (let i = deduped.length - 1; i >= 0; i--) {
|
|
32
|
-
const { start, end } = deduped[i];
|
|
33
|
-
result =
|
|
34
|
-
result.slice(0, start) +
|
|
35
|
-
maskEveryOtherChar(text.slice(start, end)) +
|
|
36
|
-
result.slice(end);
|
|
37
|
-
}
|
|
38
|
-
return result;
|
|
21
|
+
return applyMaskToSpans(text, deduplicateSpans(spans));
|
|
39
22
|
}
|
|
40
23
|
export function maskSensitiveData(text) {
|
|
41
24
|
if (!text)
|
|
@@ -1 +1,7 @@
|
|
|
1
|
+
export type Span = {
|
|
2
|
+
start: number;
|
|
3
|
+
end: number;
|
|
4
|
+
};
|
|
1
5
|
export declare function maskEveryOtherChar(text: string): string;
|
|
6
|
+
export declare function deduplicateSpans(spans: Span[]): Span[];
|
|
7
|
+
export declare function applyMaskToSpans(text: string, spans: Span[]): string;
|
|
@@ -5,3 +5,25 @@ export function maskEveryOtherChar(text) {
|
|
|
5
5
|
}
|
|
6
6
|
return result;
|
|
7
7
|
}
|
|
8
|
+
export function deduplicateSpans(spans) {
|
|
9
|
+
spans.sort((a, b) => a.start - b.start);
|
|
10
|
+
const result = [];
|
|
11
|
+
let lastEnd = 0;
|
|
12
|
+
for (const span of spans) {
|
|
13
|
+
if (span.start >= lastEnd) {
|
|
14
|
+
result.push(span);
|
|
15
|
+
lastEnd = span.end;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
export function applyMaskToSpans(text, spans) {
|
|
21
|
+
let result = '';
|
|
22
|
+
let position = 0;
|
|
23
|
+
for (const span of spans) {
|
|
24
|
+
result += text.slice(position, span.start);
|
|
25
|
+
result += maskEveryOtherChar(text.slice(span.start, span.end));
|
|
26
|
+
position = span.end;
|
|
27
|
+
}
|
|
28
|
+
return result + text.slice(position);
|
|
29
|
+
}
|
package/dist/security/native.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from 'module';
|
|
2
2
|
import { dirname, join } from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
|
-
import {
|
|
4
|
+
import { deduplicateSpans, applyMaskToSpans } from './maskUtils.js';
|
|
5
5
|
import { allRegexPatterns } from './regexes/index.js';
|
|
6
6
|
const _require = createRequire(import.meta.url);
|
|
7
7
|
const _dir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -122,38 +122,21 @@ function sanitizeWithJsFallback(content, filePath) {
|
|
|
122
122
|
function maskWithJsFallback(text) {
|
|
123
123
|
if (!text)
|
|
124
124
|
return text;
|
|
125
|
-
const
|
|
125
|
+
const spans = [];
|
|
126
126
|
for (const pattern of allRegexPatterns) {
|
|
127
127
|
if (pattern.fileContext)
|
|
128
128
|
continue;
|
|
129
129
|
const regex = cloneGlobalRegex(pattern.regex);
|
|
130
130
|
let match;
|
|
131
131
|
while ((match = regex.exec(text)) !== null) {
|
|
132
|
-
|
|
132
|
+
spans.push({ start: match.index, end: match.index + match[0].length });
|
|
133
133
|
if (match[0].length === 0)
|
|
134
134
|
regex.lastIndex++;
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
|
-
if (
|
|
137
|
+
if (spans.length === 0)
|
|
138
138
|
return text;
|
|
139
|
-
|
|
140
|
-
const nonOverlapping = [];
|
|
141
|
-
let lastEnd = 0;
|
|
142
|
-
for (const match of matches) {
|
|
143
|
-
if (match.start >= lastEnd) {
|
|
144
|
-
nonOverlapping.push(match);
|
|
145
|
-
lastEnd = match.end;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
let result = '';
|
|
149
|
-
let position = 0;
|
|
150
|
-
for (const match of nonOverlapping) {
|
|
151
|
-
result += text.slice(position, match.start);
|
|
152
|
-
result += maskEveryOtherChar(text.slice(match.start, match.end));
|
|
153
|
-
position = match.end;
|
|
154
|
-
}
|
|
155
|
-
result += text.slice(position);
|
|
156
|
-
return result;
|
|
139
|
+
return applyMaskToSpans(text, deduplicateSpans(spans));
|
|
157
140
|
}
|
|
158
141
|
export const nativeSanitizeContent = (content, filePath) => getNativeModule()?.sanitizeContent(content, filePath) ??
|
|
159
142
|
sanitizeWithJsFallback(content, filePath);
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Path-access block list: prevents reading from sensitive directories.
|
|
2
|
+
// SYNC NOTE: discoveryFilter.ts:DISCOVERY_IGNORED_FOLDER_NAMES overlaps this
|
|
3
|
+
// list (e.g. .git, .aws, .ssh, .docker, .kube). Both lists must be kept in
|
|
4
|
+
// sync — changes here that protect against directory traversal attacks should
|
|
5
|
+
// be reflected there, and vice versa.
|
|
1
6
|
export const IGNORED_PATH_PATTERNS = [
|
|
2
7
|
/(?:^|\/)\.git(?:\/|$)/,
|
|
3
8
|
/(?:^|\/)\.ssh(?:\/|$)/,
|
|
@@ -12,7 +12,10 @@ export class PathValidator {
|
|
|
12
12
|
if (opts.workspaceRoot) {
|
|
13
13
|
this.addAllowedRoot(opts.workspaceRoot);
|
|
14
14
|
}
|
|
15
|
-
|
|
15
|
+
// Home dir is opt-in. Local tools intentionally include it (users search
|
|
16
|
+
// ~/projects, ~/Documents, etc.) and rely on ignoredPathFilter as the
|
|
17
|
+
// second layer to block .ssh, .aws, .kube, etc. within it.
|
|
18
|
+
if (opts.includeHomeDir === true) {
|
|
16
19
|
const homeDir = os.homedir();
|
|
17
20
|
if (homeDir && !this.allowedRoots.includes(homeDir)) {
|
|
18
21
|
this.allowedRoots.push(homeDir);
|
|
@@ -49,23 +52,21 @@ export class PathValidator {
|
|
|
49
52
|
if (!this.allowedRoots.includes(resolvedRoot)) {
|
|
50
53
|
this.allowedRoots.push(resolvedRoot);
|
|
51
54
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return true;
|
|
57
|
-
}
|
|
58
|
-
try {
|
|
59
|
-
const realRoot = fs.realpathSync(root);
|
|
60
|
-
if (absolutePath === root) {
|
|
61
|
-
return resolvedPath === realRoot;
|
|
62
|
-
}
|
|
63
|
-
return resolvedPath.startsWith(realRoot + path.sep);
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
return false;
|
|
55
|
+
try {
|
|
56
|
+
const realRoot = fs.realpathSync(resolvedRoot);
|
|
57
|
+
if (!this.allowedRoots.includes(realRoot)) {
|
|
58
|
+
this.allowedRoots.push(realRoot);
|
|
67
59
|
}
|
|
68
|
-
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Non-existent roots are still useful for validating future output paths.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
isResolvedPathAllowed(_absolutePath, resolvedPath) {
|
|
66
|
+
// addAllowedRoot already resolves both path.resolve() and realpathSync()
|
|
67
|
+
// and pushes both into allowedRoots, so a plain string comparison is
|
|
68
|
+
// sufficient here — no need for another realpathSync per validate() call.
|
|
69
|
+
return this.allowedRoots.some(root => resolvedPath === root || resolvedPath.startsWith(root + path.sep));
|
|
69
70
|
}
|
|
70
71
|
validate(inputPath) {
|
|
71
72
|
if (!inputPath || inputPath.trim() === '') {
|
|
@@ -76,24 +77,6 @@ export class PathValidator {
|
|
|
76
77
|
}
|
|
77
78
|
const expandedPath = this.expandTilde(inputPath);
|
|
78
79
|
const absolutePath = path.resolve(expandedPath);
|
|
79
|
-
const isAllowed = this.allowedRoots.some(root => {
|
|
80
|
-
if (absolutePath === root) {
|
|
81
|
-
return true;
|
|
82
|
-
}
|
|
83
|
-
return absolutePath.startsWith(root + path.sep);
|
|
84
|
-
});
|
|
85
|
-
if (!isAllowed) {
|
|
86
|
-
return {
|
|
87
|
-
isValid: false,
|
|
88
|
-
error: `Path '${redactPath(inputPath)}' is outside allowed directories`,
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
if (shouldIgnore(absolutePath)) {
|
|
92
|
-
return {
|
|
93
|
-
isValid: false,
|
|
94
|
-
error: `Path '${redactPath(inputPath)}' is in an ignored directory or matches an ignored pattern`,
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
80
|
try {
|
|
98
81
|
const realPath = fs.realpathSync(absolutePath);
|
|
99
82
|
const isRealPathAllowed = this.isResolvedPathAllowed(absolutePath, realPath);
|
|
@@ -103,6 +86,12 @@ export class PathValidator {
|
|
|
103
86
|
error: `Symlink target '${redactPath(realPath)}' is outside allowed directories`,
|
|
104
87
|
};
|
|
105
88
|
}
|
|
89
|
+
if (shouldIgnore(absolutePath)) {
|
|
90
|
+
return {
|
|
91
|
+
isValid: false,
|
|
92
|
+
error: `Path '${redactPath(inputPath)}' is in an ignored directory or matches an ignored pattern`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
106
95
|
if (shouldIgnore(realPath)) {
|
|
107
96
|
return {
|
|
108
97
|
isValid: false,
|
|
@@ -177,6 +166,12 @@ export class PathValidator {
|
|
|
177
166
|
error: `Path '${redactPath(inputPath)}' is in an ignored directory or matches an ignored pattern`,
|
|
178
167
|
};
|
|
179
168
|
}
|
|
169
|
+
if (shouldIgnore(resolvedPath)) {
|
|
170
|
+
return {
|
|
171
|
+
isValid: false,
|
|
172
|
+
error: `Path '${redactPath(inputPath)}' resolves into an ignored directory or matches an ignored pattern`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
180
175
|
return {
|
|
181
176
|
isValid: true,
|
|
182
177
|
sanitizedPath: resolvedPath,
|
|
@@ -221,9 +216,15 @@ export class PathValidator {
|
|
|
221
216
|
this.allowedRoots = [...roots];
|
|
222
217
|
}
|
|
223
218
|
}
|
|
224
|
-
|
|
219
|
+
// The production singleton explicitly opts in to the home dir because local
|
|
220
|
+
// tools are expected to search ~/projects, ~/Documents, etc. Security within
|
|
221
|
+
// the home directory is provided by ignoredPathFilter (.ssh, .aws, .kube, etc.).
|
|
222
|
+
export const pathValidator = new PathValidator({ includeHomeDir: true });
|
|
225
223
|
export function resetPathValidator(options) {
|
|
226
|
-
|
|
224
|
+
// When called with no arguments (e.g. in afterEach tear-down), restore the
|
|
225
|
+
// same includeHomeDir state the singleton starts with.
|
|
226
|
+
const effective = options ?? { includeHomeDir: true };
|
|
227
|
+
const newValidator = new PathValidator(effective);
|
|
227
228
|
pathValidator.replaceAllowedRoots(newValidator.getAllowedRoots());
|
|
228
229
|
return pathValidator;
|
|
229
230
|
}
|
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
import { normalizeCommandName } from './commandUtils.js';
|
|
2
|
-
const REDOS_TIMEOUT_MS =
|
|
3
|
-
|
|
2
|
+
const REDOS_TIMEOUT_MS = 200;
|
|
3
|
+
// Use multiple input lengths to catch ReDoS patterns that only blow up at larger sizes.
|
|
4
|
+
const REDOS_TEST_INPUTS = [
|
|
5
|
+
'a'.repeat(50),
|
|
6
|
+
'a'.repeat(500),
|
|
7
|
+
'a'.repeat(2000),
|
|
8
|
+
];
|
|
4
9
|
function isReDoSSafe(regex) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
// Timing heuristic — catches obvious exponential backtracking but cannot provide
|
|
11
|
+
// structural guarantees. Tests multiple input sizes to catch patterns that only
|
|
12
|
+
// blow up at larger scales. A proper linear-time checker (e.g., re2) would be
|
|
13
|
+
// the gold standard but adds a native dependency.
|
|
14
|
+
for (const input of REDOS_TEST_INPUTS) {
|
|
15
|
+
regex.lastIndex = 0;
|
|
16
|
+
const start = performance.now();
|
|
17
|
+
try {
|
|
18
|
+
regex.test(input);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
regex.lastIndex = 0;
|
|
25
|
+
}
|
|
26
|
+
if (performance.now() - start >= REDOS_TIMEOUT_MS) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
11
29
|
}
|
|
12
|
-
return
|
|
30
|
+
return true;
|
|
13
31
|
}
|
|
14
32
|
export class SecurityRegistry {
|
|
15
33
|
_extraSecretPatterns = [];
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export declare const ALLOWED_COMMANDS: readonly ["rg", "ls", "find", "grep", "git"];
|
|
1
|
+
export declare const ALLOWED_COMMANDS: readonly ["rg", "ls", "find", "grep", "git", "file", "zcat", "gunzip", "bzcat", "xzcat", "zstdcat", "zstd", "lz4cat", "brotli", "lzfse", "tar", "unzip", "bsdtar", "7z", "7zz"];
|
|
2
2
|
export declare const DANGEROUS_PATTERNS: readonly [RegExp, RegExp, RegExp];
|
|
3
3
|
export declare const PATTERN_DANGEROUS_PATTERNS: readonly [RegExp, RegExp, RegExp, RegExp];
|
|
@@ -1,4 +1,25 @@
|
|
|
1
|
-
export const ALLOWED_COMMANDS = [
|
|
1
|
+
export const ALLOWED_COMMANDS = [
|
|
2
|
+
'rg',
|
|
3
|
+
'ls',
|
|
4
|
+
'find',
|
|
5
|
+
'grep',
|
|
6
|
+
'git',
|
|
7
|
+
'file',
|
|
8
|
+
'zcat',
|
|
9
|
+
'gunzip',
|
|
10
|
+
'bzcat',
|
|
11
|
+
'xzcat',
|
|
12
|
+
'zstdcat',
|
|
13
|
+
'zstd',
|
|
14
|
+
'lz4cat',
|
|
15
|
+
'brotli',
|
|
16
|
+
'lzfse',
|
|
17
|
+
'tar',
|
|
18
|
+
'unzip',
|
|
19
|
+
'bsdtar',
|
|
20
|
+
'7z',
|
|
21
|
+
'7zz',
|
|
22
|
+
];
|
|
2
23
|
export const DANGEROUS_PATTERNS = [
|
|
3
24
|
/[;&|`$(){}[\]<>]/, // Shell metacharacters
|
|
4
25
|
/\${/,
|
|
@@ -13,13 +13,24 @@ export function configureSecurity(deps) {
|
|
|
13
13
|
function createErrorResult(text) {
|
|
14
14
|
return { content: [{ type: 'text', text }], isError: true };
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
// Combine an external caller-provided AbortSignal with an internal one so that
|
|
17
|
+
// aborting either cancels the tool. Returns the single source unchanged when
|
|
18
|
+
// only one (or none) is present, preserving existing behavior.
|
|
19
|
+
function mergeAbortSignals(external, internal) {
|
|
20
|
+
if (!external)
|
|
21
|
+
return internal;
|
|
22
|
+
if (!internal)
|
|
23
|
+
return external;
|
|
24
|
+
return AbortSignal.any([external, internal]);
|
|
25
|
+
}
|
|
26
|
+
function withToolTimeout(toolName, promise, signal, timeoutMs, onTimeout) {
|
|
17
27
|
const timeout = getTimeoutMs(timeoutMs);
|
|
18
28
|
if (signal?.aborted) {
|
|
19
29
|
return Promise.resolve(createErrorResult(`Tool '${toolName}' was cancelled before execution.`));
|
|
20
30
|
}
|
|
21
31
|
return new Promise(resolve => {
|
|
22
32
|
const timer = setTimeout(() => {
|
|
33
|
+
onTimeout?.();
|
|
23
34
|
resolve(createErrorResult(`Tool '${toolName}' timed out after ${timeout / 1000}s. Try reducing query complexity or scope.`));
|
|
24
35
|
}, timeout);
|
|
25
36
|
const onAbort = () => {
|
|
@@ -27,6 +38,9 @@ function withToolTimeout(toolName, promise, signal, timeoutMs) {
|
|
|
27
38
|
resolve(createErrorResult(`Tool '${toolName}' was cancelled by the client.`));
|
|
28
39
|
};
|
|
29
40
|
signal?.addEventListener('abort', onAbort, { once: true });
|
|
41
|
+
// Re-check after registering the listener: the signal may have been aborted
|
|
42
|
+
// in the window between addEventListener and this line, which the listener
|
|
43
|
+
// alone cannot catch.
|
|
30
44
|
if (signal?.aborted) {
|
|
31
45
|
clearTimeout(timer);
|
|
32
46
|
signal.removeEventListener('abort', onAbort);
|
|
@@ -47,7 +61,7 @@ function withToolTimeout(toolName, promise, signal, timeoutMs) {
|
|
|
47
61
|
});
|
|
48
62
|
}
|
|
49
63
|
async function runSecure(opts) {
|
|
50
|
-
const { toolName, handler, args, authInfo, sessionId, signal, timeoutMs } = opts;
|
|
64
|
+
const { toolName, handler, args, authInfo, sessionId, signal, timeoutMs, onTimeout, abortSignal, } = opts;
|
|
51
65
|
try {
|
|
52
66
|
const sanitizer = getSanitizer();
|
|
53
67
|
const validation = sanitizer.validateInputParameters(args);
|
|
@@ -55,7 +69,8 @@ async function runSecure(opts) {
|
|
|
55
69
|
return createErrorResult(`Security validation failed: ${validation.warnings.join('; ')}`);
|
|
56
70
|
}
|
|
57
71
|
const sanitizedParams = validation.sanitizedParams;
|
|
58
|
-
const
|
|
72
|
+
const mergedSignal = mergeAbortSignals(signal, abortSignal);
|
|
73
|
+
const rawResult = await withToolTimeout(toolName, handler(sanitizedParams, authInfo, sessionId), mergedSignal, timeoutMs, onTimeout);
|
|
59
74
|
return rawResult;
|
|
60
75
|
}
|
|
61
76
|
catch (error) {
|