@octocodeai/octocode-engine 16.6.2 → 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 +150 -109
- package/dist/lsp/client.d.ts +17 -9
- package/dist/lsp/client.js +25 -25
- package/dist/lsp/config.js +1 -1
- package/dist/lsp/lspClientPool.d.ts +2 -0
- package/dist/lsp/lspClientPool.js +9 -4
- package/dist/lsp/manager.js +25 -3
- package/dist/lsp/native.d.ts +2 -1
- package/dist/lsp/resolver.js +10 -4
- package/dist/lsp/serverManifest.d.ts +5 -4
- package/dist/lsp/serverManifest.js +44 -6
- package/dist/lsp/serverManifestData.js +85 -22
- package/dist/lsp/serverProvisioner.js +13 -4
- package/dist/lsp/types.d.ts +11 -0
- package/dist/security/commandValidator.js +64 -0
- package/dist/security/contentSanitizer.js +4 -3
- package/dist/security/discoveryFilter.js +3 -0
- package/dist/security/ignoredPathFilter.js +5 -2
- package/dist/security/pathValidator.js +17 -19
- 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 +15 -3
- package/docs/LSP_SERVER_LIFECYCLE.md +258 -0
- package/index.d.ts +8 -2
- package/package.json +12 -9
package/dist/lsp/config.js
CHANGED
|
@@ -137,7 +137,7 @@ function withBundledJsServer(config) {
|
|
|
137
137
|
}
|
|
138
138
|
// 2) Language-keyed: a different bundled server than the native default
|
|
139
139
|
// (e.g. pyright for Python); use the bundled server's own launch args.
|
|
140
|
-
const byLanguage = config.languageId
|
|
140
|
+
const byLanguage = path.basename(config.command) === config.command && config.languageId
|
|
141
141
|
? BUNDLED_BY_LANGUAGE[config.languageId]
|
|
142
142
|
: undefined;
|
|
143
143
|
if (byLanguage) {
|
|
@@ -21,7 +21,9 @@ export declare class LspClientPool<T extends PooledClient> {
|
|
|
21
21
|
clearAll(): Promise<void>;
|
|
22
22
|
size(): number;
|
|
23
23
|
keys(): PoolKey[];
|
|
24
|
+
has(key: PoolKey): boolean;
|
|
24
25
|
private resetIdleTimer;
|
|
25
26
|
private startIdleTimer;
|
|
26
27
|
}
|
|
28
|
+
export declare function serializeKey(key: PoolKey): string;
|
|
27
29
|
export {};
|
|
@@ -53,6 +53,10 @@ export class LspClientPool {
|
|
|
53
53
|
keys() {
|
|
54
54
|
return [...this.entries.values()].map(entry => entry.key);
|
|
55
55
|
}
|
|
56
|
+
has(key) {
|
|
57
|
+
const k = serializeKey(key);
|
|
58
|
+
return this.entries.has(k) || this.inflight.has(k);
|
|
59
|
+
}
|
|
56
60
|
resetIdleTimer(k) {
|
|
57
61
|
const entry = this.entries.get(k);
|
|
58
62
|
if (!entry)
|
|
@@ -68,13 +72,14 @@ export class LspClientPool {
|
|
|
68
72
|
this.entries.delete(k);
|
|
69
73
|
void safeStop(entry.client);
|
|
70
74
|
}, this.options.idleTimeoutMs);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
// This package is Node-only (napi-rs). The timer is always a NodeJS.Timeout
|
|
76
|
+
// with an unref() method; calling it keeps the idle timer from preventing
|
|
77
|
+
// a clean process exit when no real work is in flight.
|
|
78
|
+
timer.unref?.();
|
|
74
79
|
return timer;
|
|
75
80
|
}
|
|
76
81
|
}
|
|
77
|
-
function serializeKey(key) {
|
|
82
|
+
export function serializeKey(key) {
|
|
78
83
|
return `${key.serverId ?? key.languageId}\u0000${key.workspaceRoot}`;
|
|
79
84
|
}
|
|
80
85
|
async function safeStop(client) {
|
package/dist/lsp/manager.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { LSPClient } from './client.js';
|
|
2
2
|
import { getLanguageServerForFile, resolveServerForFile, } from './config.js';
|
|
3
|
-
import { LspClientPool } from './lspClientPool.js';
|
|
3
|
+
import { LspClientPool, serializeKey } from './lspClientPool.js';
|
|
4
4
|
import { manifestInstallHint } from './serverManifest.js';
|
|
5
5
|
import { resolveWorkspaceRootForFile } from './workspaceRoot.js';
|
|
6
6
|
export async function isLanguageServerAvailable(filePath, workspaceRoot) {
|
|
@@ -66,10 +66,24 @@ const DEFAULT_READY_TIMEOUT_MS = 30_000;
|
|
|
66
66
|
function readyTimeoutForLanguage(languageId) {
|
|
67
67
|
return SERVER_READY_TIMEOUT_MS[languageId] ?? DEFAULT_READY_TIMEOUT_MS;
|
|
68
68
|
}
|
|
69
|
+
// Eliminates the double getLanguageServerForFile call that would otherwise
|
|
70
|
+
// happen once in poolKeyForFile (to build the key) and again inside the factory
|
|
71
|
+
// (to create the client). poolKeyForFile deposits the already-resolved config
|
|
72
|
+
// here before calling sharedPool.acquire; the factory reads and clears it.
|
|
73
|
+
//
|
|
74
|
+
// The deposit is conditional: when sharedPool already has an entry or an
|
|
75
|
+
// inflight promise for this key, acquire() returns the cached client or the
|
|
76
|
+
// existing promise WITHOUT invoking the factory — so depositing again here
|
|
77
|
+
// would never be read or cleared and would leak the entry forever. Key format
|
|
78
|
+
// is the shared serializeKey from lspClientPool.ts.
|
|
79
|
+
const _pendingConfigs = new Map();
|
|
69
80
|
const sharedPool = new LspClientPool({
|
|
70
81
|
idleTimeoutMs: POOL_IDLE_TIMEOUT_MS,
|
|
71
82
|
factory: async (key) => {
|
|
72
|
-
const
|
|
83
|
+
const cacheKey = serializeKey(key);
|
|
84
|
+
const serverConfig = _pendingConfigs.get(cacheKey) ??
|
|
85
|
+
(await getLanguageServerForFile(synthesizeFilePathForKey(key), key.workspaceRoot));
|
|
86
|
+
_pendingConfigs.delete(cacheKey);
|
|
73
87
|
if (!serverConfig)
|
|
74
88
|
return null;
|
|
75
89
|
const client = new LSPClient(serverConfig);
|
|
@@ -147,10 +161,18 @@ async function poolKeyForFile(workspaceRoot, filePath) {
|
|
|
147
161
|
const serverConfig = await getLanguageServerForFile(filePath, workspaceRoot);
|
|
148
162
|
if (!serverConfig)
|
|
149
163
|
return null;
|
|
150
|
-
|
|
164
|
+
const key = {
|
|
151
165
|
workspaceRoot,
|
|
152
166
|
filePath,
|
|
153
167
|
languageId: serverConfig.languageId ?? 'unknown',
|
|
154
168
|
serverId: `${serverConfig.command} ${(serverConfig.args ?? []).join(' ')}`.trim(),
|
|
155
169
|
};
|
|
170
|
+
const serialized = serializeKey(key);
|
|
171
|
+
// Only deposit when the pool will actually call the factory for this key.
|
|
172
|
+
// If there's already an entry or inflight, acquire() won't start a new factory
|
|
173
|
+
// run — so depositing here would permanently leak the entry.
|
|
174
|
+
if (!sharedPool.has(key)) {
|
|
175
|
+
_pendingConfigs.set(serialized, serverConfig);
|
|
176
|
+
}
|
|
177
|
+
return key;
|
|
156
178
|
}
|
package/dist/lsp/native.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import type { LspReadiness } from './types.js';
|
|
1
2
|
export type NativeLspClientBinding = {
|
|
2
3
|
start(): Promise<void>;
|
|
3
4
|
stop(): Promise<void>;
|
|
4
|
-
waitForReady(timeoutMs?: number): Promise<
|
|
5
|
+
waitForReady(timeoutMs?: number): Promise<LspReadiness | undefined>;
|
|
5
6
|
hasCapability?(capability: string): boolean;
|
|
6
7
|
getRecentStderr?(): string[];
|
|
7
8
|
openDocument(filePath: string, content: string): Promise<void>;
|
package/dist/lsp/resolver.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
1
|
+
import { access, constants, readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { nativeBinding } from './native.js';
|
|
4
4
|
export class SymbolResolutionError extends Error {
|
|
@@ -7,7 +7,7 @@ export class SymbolResolutionError extends Error {
|
|
|
7
7
|
reason;
|
|
8
8
|
searchRadius;
|
|
9
9
|
constructor(symbolName, lineHint, reason, searchRadius = 5) {
|
|
10
|
-
super(`Could not find symbol '${symbolName}' at or near line ${lineHint}
|
|
10
|
+
super(`Could not find symbol '${symbolName}' at or near line ${lineHint}.${reason ? ` ${reason}` : ''}`);
|
|
11
11
|
this.symbolName = symbolName;
|
|
12
12
|
this.lineHint = lineHint;
|
|
13
13
|
this.reason = reason;
|
|
@@ -27,7 +27,13 @@ function normalizeResolvedSymbol(value) {
|
|
|
27
27
|
function toSymbolResolutionError(error, fuzzy, searchRadius = 5) {
|
|
28
28
|
if (error instanceof SymbolResolutionError)
|
|
29
29
|
return error;
|
|
30
|
-
const
|
|
30
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
31
|
+
// The native resolver already emits the canonical "Could not find symbol …"
|
|
32
|
+
// sentence; strip it so the wrapper message doesn't repeat it.
|
|
33
|
+
const prefix = `Could not find symbol '${fuzzy.symbolName}' at or near line ${fuzzy.lineHint ?? 0}`;
|
|
34
|
+
const reason = raw.startsWith(prefix)
|
|
35
|
+
? raw.slice(prefix.length).replace(/^[.\s]+/, '')
|
|
36
|
+
: raw;
|
|
31
37
|
return new SymbolResolutionError(fuzzy.symbolName, fuzzy.lineHint ?? 0, reason, searchRadius);
|
|
32
38
|
}
|
|
33
39
|
export function resolveSymbolPosition(fileOrContent, fuzzyOrSymbolName, lineHint, orderHint) {
|
|
@@ -152,7 +158,7 @@ async function resolveLocalModulePath(importerPath, moduleSpecifier) {
|
|
|
152
158
|
];
|
|
153
159
|
for (const candidate of candidates) {
|
|
154
160
|
try {
|
|
155
|
-
await
|
|
161
|
+
await access(candidate, constants.R_OK);
|
|
156
162
|
return candidate;
|
|
157
163
|
}
|
|
158
164
|
catch {
|
|
@@ -7,7 +7,7 @@ import { type PlatformId } from './platform.js';
|
|
|
7
7
|
* managed-cache locator.
|
|
8
8
|
*
|
|
9
9
|
* Provisioning policy (see docs/context/LSP_GUIDE.md):
|
|
10
|
-
* OCTOCODE_LSP_AUTO_INSTALL =
|
|
10
|
+
* OCTOCODE_LSP_AUTO_INSTALL = prompt (default) | off | auto
|
|
11
11
|
* Live network download is gated and requires a pinned `sha256` per asset.
|
|
12
12
|
* Until SHAs are pinned the manifest still drives (a) honest detect-and-instruct
|
|
13
13
|
* guidance and (b) reuse of a server a user/CI has pre-populated into the
|
|
@@ -61,9 +61,10 @@ export declare function cachedServerBinPath(serverName: string, platformId?: Pla
|
|
|
61
61
|
/**
|
|
62
62
|
* If a managed binary is already present AND verified in the cache (downloaded
|
|
63
63
|
* by a prior run, by CI, or pre-baked), return its absolute path. A binary is
|
|
64
|
-
* only trusted when its sibling `<binName>.ok` completion marker
|
|
65
|
-
*
|
|
66
|
-
*
|
|
64
|
+
* only trusted when its sibling `<binName>.ok` completion marker contains the
|
|
65
|
+
* current binary's SHA-256 and size. A half-written or tampered binary from an
|
|
66
|
+
* interrupted install has no matching marker and is ignored, so it never
|
|
67
|
+
* resolves as "installed". Read-only — always safe to call.
|
|
67
68
|
*/
|
|
68
69
|
export declare function resolveCachedServer(serverName: string, platformId?: PlatformId): string | null;
|
|
69
70
|
/** Human-readable provisioning guidance for a server with no resolved binary. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { detectPlatformId } from './platform.js';
|
|
@@ -6,6 +7,30 @@ import { MANIFEST } from './serverManifestData.js';
|
|
|
6
7
|
function loadManifest() {
|
|
7
8
|
return MANIFEST;
|
|
8
9
|
}
|
|
10
|
+
function sha256File(filePath) {
|
|
11
|
+
try {
|
|
12
|
+
return createHash('sha256').update(readFileSync(filePath)).digest('hex');
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function readCacheMarker(markerPath) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(readFileSync(markerPath, 'utf8'));
|
|
21
|
+
if (typeof parsed.binarySha256 !== 'string' ||
|
|
22
|
+
!/^[0-9a-f]{64}$/u.test(parsed.binarySha256) ||
|
|
23
|
+
typeof parsed.size !== 'number' ||
|
|
24
|
+
!Number.isSafeInteger(parsed.size) ||
|
|
25
|
+
parsed.size < 0) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return { binarySha256: parsed.binarySha256, size: parsed.size };
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
9
34
|
/** The configured auto-install policy. Defaults to `prompt` when unset (asks before downloading). Set OCTOCODE_LSP_AUTO_INSTALL=off to disable all downloads, or =auto to skip the prompt. */
|
|
10
35
|
export function provisionMode(env = process.env) {
|
|
11
36
|
const raw = (env.OCTOCODE_LSP_AUTO_INSTALL ?? '').trim().toLowerCase();
|
|
@@ -51,15 +76,28 @@ export function cachedServerBinPath(serverName, platformId = detectPlatformId())
|
|
|
51
76
|
/**
|
|
52
77
|
* If a managed binary is already present AND verified in the cache (downloaded
|
|
53
78
|
* by a prior run, by CI, or pre-baked), return its absolute path. A binary is
|
|
54
|
-
* only trusted when its sibling `<binName>.ok` completion marker
|
|
55
|
-
*
|
|
56
|
-
*
|
|
79
|
+
* only trusted when its sibling `<binName>.ok` completion marker contains the
|
|
80
|
+
* current binary's SHA-256 and size. A half-written or tampered binary from an
|
|
81
|
+
* interrupted install has no matching marker and is ignored, so it never
|
|
82
|
+
* resolves as "installed". Read-only — always safe to call.
|
|
57
83
|
*/
|
|
58
84
|
export function resolveCachedServer(serverName, platformId = detectPlatformId()) {
|
|
59
85
|
const binPath = cachedServerBinPath(serverName, platformId);
|
|
60
|
-
if (!binPath)
|
|
86
|
+
if (!binPath || !existsSync(binPath))
|
|
87
|
+
return null;
|
|
88
|
+
const marker = readCacheMarker(`${binPath}.ok`);
|
|
89
|
+
if (!marker)
|
|
90
|
+
return null;
|
|
91
|
+
let size;
|
|
92
|
+
try {
|
|
93
|
+
size = statSync(binPath).size;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (size !== marker.size)
|
|
61
99
|
return null;
|
|
62
|
-
return
|
|
100
|
+
return sha256File(binPath) === marker.binarySha256 ? binPath : null;
|
|
63
101
|
}
|
|
64
102
|
/** Human-readable provisioning guidance for a server with no resolved binary. */
|
|
65
103
|
export function manifestInstallHint(serverName) {
|
|
@@ -1,33 +1,96 @@
|
|
|
1
1
|
export const MANIFEST = {
|
|
2
|
-
"$comment": "Auto-download manifest for portable, toolchain-free language servers. Verified 2026-06-28 against live GitHub Releases. SHA-256 pinned per asset — download is refused if the checksum mismatches. Scope: Rust (rust-analyzer, gz, auto-download ready), C/C++ (clangd, zip, auto-download ready). Toolchain-coupled servers (gopls/go, jdtls/jre) are detect-and-instruct; pure-JS servers (typescript-language-server, pyright, yaml/json/html/css) are bundled npm deps. Markdown/MDX handled by the MINIFIER, NOT LSP.",
|
|
2
|
+
"$comment": "Auto-download manifest for portable, toolchain-free language servers. Verified 2026-06-28 against live GitHub Releases. SHA-256 pinned per asset — download is refused if the checksum mismatches. Scope: Rust (rust-analyzer, gz, auto-download ready), C/C++ (clangd, zip, auto-download ready). Toolchain-coupled servers (gopls/go, jdtls/jre) are detect-and-instruct; pure-JS servers (typescript-language-server, pyright, yaml/json/html/css) are bundled npm deps. Markdown/MDX handled by the MINIFIER, NOT LSP. This JSON is the source of truth; src/lsp/serverManifestData.ts is generated from it by scripts/gen-server-manifest.mjs (run via `yarn manifest:gen` / the `verify:manifest` gate).",
|
|
3
3
|
"version": 1,
|
|
4
4
|
"servers": {
|
|
5
|
-
"rust-analyzer": {
|
|
6
|
-
"languageId": "rust",
|
|
7
|
-
"repo": "rust-lang/rust-analyzer",
|
|
8
|
-
"releaseTag": "2026-06-22",
|
|
9
|
-
"platforms": {
|
|
10
|
-
"darwin-arm64": { "url": "https://github.com/rust-lang/rust-analyzer/releases/download/2026-06-22/rust-analyzer-aarch64-apple-darwin.gz", "archive": "gz", "binName": "rust-analyzer", "sha256": "c8cdf6d5e488752b907d5ee15e31768b59a78d992e9a54b9f9660e1bfdf39f27" },
|
|
11
|
-
"darwin-x64": { "url": "https://github.com/rust-lang/rust-analyzer/releases/download/2026-06-22/rust-analyzer-x86_64-apple-darwin.gz", "archive": "gz", "binName": "rust-analyzer", "sha256": "feb7c170d2c1a2e4b8a88ac73f937eddb576828e3821b0a63ee0e64bd0bc9440" },
|
|
12
|
-
"linux-x64": { "url": "https://github.com/rust-lang/rust-analyzer/releases/download/2026-06-22/rust-analyzer-x86_64-unknown-linux-gnu.gz", "archive": "gz", "binName": "rust-analyzer", "sha256": "9602ca5b24dcaa07a5a021274763bed367d8a32da9a226fe3e139de3306569cb" },
|
|
13
|
-
"linux-x64-musl": { "url": "https://github.com/rust-lang/rust-analyzer/releases/download/2026-06-22/rust-analyzer-x86_64-unknown-linux-musl.gz", "archive": "gz", "binName": "rust-analyzer", "sha256": "fe1d7b0e9733f7a439e4b6f27b8c4cc7afd87ae28fc5b496eb8df31d674b78dd" },
|
|
14
|
-
"linux-arm64": { "url": "https://github.com/rust-lang/rust-analyzer/releases/download/2026-06-22/rust-analyzer-aarch64-unknown-linux-gnu.gz", "archive": "gz", "binName": "rust-analyzer", "sha256": "bf65b0d4586f127ab11bf33476dd6aac82dad173946c5d3b1cede19d63ae85ed" },
|
|
15
|
-
"win32-x64": { "url": "https://github.com/rust-lang/rust-analyzer/releases/download/2026-06-22/rust-analyzer-x86_64-pc-windows-msvc.zip", "archive": "zip", "binPath": "rust-analyzer.exe", "binName": "rust-analyzer.exe", "sha256": "6071dc5b28aa6d22c715f63c08d75b827c066be4ea866796587e52ed48b2922f" },
|
|
16
|
-
"win32-arm64": { "url": "https://github.com/rust-lang/rust-analyzer/releases/download/2026-06-22/rust-analyzer-aarch64-pc-windows-msvc.zip", "archive": "zip", "binPath": "rust-analyzer.exe", "binName": "rust-analyzer.exe", "sha256": "30f873713ea3663db10999c23e95b74fe19968c893d5c0e9b8a896b31dbf8cf8" }
|
|
17
|
-
}
|
|
18
|
-
},
|
|
19
5
|
"clangd": {
|
|
20
6
|
"languageId": "cpp",
|
|
21
|
-
"repo": "clangd/clangd",
|
|
22
|
-
"releaseTag": "22.1.0",
|
|
23
7
|
"launchArgs": [],
|
|
24
8
|
"platforms": {
|
|
25
|
-
"darwin-arm64": {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
9
|
+
"darwin-arm64": {
|
|
10
|
+
"url": "https://github.com/clangd/clangd/releases/download/22.1.0/clangd-mac-22.1.0.zip",
|
|
11
|
+
"archive": "zip",
|
|
12
|
+
"binPath": "clangd_22.1.0/bin/clangd",
|
|
13
|
+
"binName": "clangd",
|
|
14
|
+
"sha256": "e31e271fe11f6dcd7cf87ca74be4a12788ff8ce5a0b07762583e335c058e939a"
|
|
15
|
+
},
|
|
16
|
+
"darwin-x64": {
|
|
17
|
+
"url": "https://github.com/clangd/clangd/releases/download/22.1.0/clangd-mac-22.1.0.zip",
|
|
18
|
+
"archive": "zip",
|
|
19
|
+
"binPath": "clangd_22.1.0/bin/clangd",
|
|
20
|
+
"binName": "clangd",
|
|
21
|
+
"sha256": "e31e271fe11f6dcd7cf87ca74be4a12788ff8ce5a0b07762583e335c058e939a"
|
|
22
|
+
},
|
|
23
|
+
"linux-x64": {
|
|
24
|
+
"url": "https://github.com/clangd/clangd/releases/download/22.1.0/clangd-linux-22.1.0.zip",
|
|
25
|
+
"archive": "zip",
|
|
26
|
+
"binPath": "clangd_22.1.0/bin/clangd",
|
|
27
|
+
"binName": "clangd",
|
|
28
|
+
"sha256": "71eddc5303da9a5bc5e8b509488b5b2c5acf45f20e33b8394e71a12a56d67198"
|
|
29
|
+
},
|
|
30
|
+
"win32-x64": {
|
|
31
|
+
"url": "https://github.com/clangd/clangd/releases/download/22.1.0/clangd-windows-22.1.0.zip",
|
|
32
|
+
"archive": "zip",
|
|
33
|
+
"binPath": "clangd_22.1.0/bin/clangd.exe",
|
|
34
|
+
"binName": "clangd.exe",
|
|
35
|
+
"sha256": "c54e57dbff3ccc9e8352367ddb7030ad3f624073ec58c7477424e7919f578572"
|
|
36
|
+
}
|
|
29
37
|
},
|
|
30
|
-
"
|
|
38
|
+
"releaseTag": "22.1.0",
|
|
39
|
+
"repo": "clangd/clangd",
|
|
40
|
+
"unsupportedPlatforms": {
|
|
41
|
+
"linux-arm64": "clangd publishes no linux-arm64 release asset; install via the system package manager."
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"rust-analyzer": {
|
|
45
|
+
"languageId": "rust",
|
|
46
|
+
"platforms": {
|
|
47
|
+
"darwin-arm64": {
|
|
48
|
+
"url": "https://github.com/rust-lang/rust-analyzer/releases/download/2026-06-22/rust-analyzer-aarch64-apple-darwin.gz",
|
|
49
|
+
"archive": "gz",
|
|
50
|
+
"binName": "rust-analyzer",
|
|
51
|
+
"sha256": "c8cdf6d5e488752b907d5ee15e31768b59a78d992e9a54b9f9660e1bfdf39f27"
|
|
52
|
+
},
|
|
53
|
+
"darwin-x64": {
|
|
54
|
+
"url": "https://github.com/rust-lang/rust-analyzer/releases/download/2026-06-22/rust-analyzer-x86_64-apple-darwin.gz",
|
|
55
|
+
"archive": "gz",
|
|
56
|
+
"binName": "rust-analyzer",
|
|
57
|
+
"sha256": "feb7c170d2c1a2e4b8a88ac73f937eddb576828e3821b0a63ee0e64bd0bc9440"
|
|
58
|
+
},
|
|
59
|
+
"linux-arm64": {
|
|
60
|
+
"url": "https://github.com/rust-lang/rust-analyzer/releases/download/2026-06-22/rust-analyzer-aarch64-unknown-linux-gnu.gz",
|
|
61
|
+
"archive": "gz",
|
|
62
|
+
"binName": "rust-analyzer",
|
|
63
|
+
"sha256": "bf65b0d4586f127ab11bf33476dd6aac82dad173946c5d3b1cede19d63ae85ed"
|
|
64
|
+
},
|
|
65
|
+
"linux-x64": {
|
|
66
|
+
"url": "https://github.com/rust-lang/rust-analyzer/releases/download/2026-06-22/rust-analyzer-x86_64-unknown-linux-gnu.gz",
|
|
67
|
+
"archive": "gz",
|
|
68
|
+
"binName": "rust-analyzer",
|
|
69
|
+
"sha256": "9602ca5b24dcaa07a5a021274763bed367d8a32da9a226fe3e139de3306569cb"
|
|
70
|
+
},
|
|
71
|
+
"linux-x64-musl": {
|
|
72
|
+
"url": "https://github.com/rust-lang/rust-analyzer/releases/download/2026-06-22/rust-analyzer-x86_64-unknown-linux-musl.gz",
|
|
73
|
+
"archive": "gz",
|
|
74
|
+
"binName": "rust-analyzer",
|
|
75
|
+
"sha256": "fe1d7b0e9733f7a439e4b6f27b8c4cc7afd87ae28fc5b496eb8df31d674b78dd"
|
|
76
|
+
},
|
|
77
|
+
"win32-arm64": {
|
|
78
|
+
"url": "https://github.com/rust-lang/rust-analyzer/releases/download/2026-06-22/rust-analyzer-aarch64-pc-windows-msvc.zip",
|
|
79
|
+
"archive": "zip",
|
|
80
|
+
"binPath": "rust-analyzer.exe",
|
|
81
|
+
"binName": "rust-analyzer.exe",
|
|
82
|
+
"sha256": "30f873713ea3663db10999c23e95b74fe19968c893d5c0e9b8a896b31dbf8cf8"
|
|
83
|
+
},
|
|
84
|
+
"win32-x64": {
|
|
85
|
+
"url": "https://github.com/rust-lang/rust-analyzer/releases/download/2026-06-22/rust-analyzer-x86_64-pc-windows-msvc.zip",
|
|
86
|
+
"archive": "zip",
|
|
87
|
+
"binPath": "rust-analyzer.exe",
|
|
88
|
+
"binName": "rust-analyzer.exe",
|
|
89
|
+
"sha256": "6071dc5b28aa6d22c715f63c08d75b827c066be4ea866796587e52ed48b2922f"
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
"releaseTag": "2026-06-22",
|
|
93
|
+
"repo": "rust-lang/rust-analyzer"
|
|
31
94
|
}
|
|
32
95
|
}
|
|
33
96
|
};
|
|
@@ -19,8 +19,8 @@ import { cachedServerBinPath, managedCacheRoot, manifestServer, provisionMode, r
|
|
|
19
19
|
* - atomic: download to a temp file in the dest dir, verify, chmod, rename
|
|
20
20
|
* - `.ok` completion marker written last, so a partial install never resolves
|
|
21
21
|
* - per-target lock so editor + CLI don't race the same download
|
|
22
|
-
* v1 handles `gz` and `none` (raw) assets with zero new deps; archive
|
|
23
|
-
* needing
|
|
22
|
+
* v1 handles `gz`, `zip`, and `none` (raw) assets with zero new deps; archive
|
|
23
|
+
* formats needing tar/xz extraction return a clear "install manually" error.
|
|
24
24
|
*/
|
|
25
25
|
const ALLOWED_HOSTS = new Set([
|
|
26
26
|
'github.com',
|
|
@@ -136,6 +136,10 @@ function extractFromZip(zipBuffer, targetPath) {
|
|
|
136
136
|
.toString('utf8')
|
|
137
137
|
.replace(/^\.?\//u, '');
|
|
138
138
|
const dataOffset = offset + 30 + filenameLen + extraLen;
|
|
139
|
+
// Guard against a malformed or truncated ZIP with bogus size fields —
|
|
140
|
+
// compressedSize is UInt32 and could overflow past the buffer.
|
|
141
|
+
if (dataOffset + compressedSize > zipBuffer.length)
|
|
142
|
+
break;
|
|
139
143
|
if (filename === normalizedTarget) {
|
|
140
144
|
const compressed = zipBuffer.subarray(dataOffset, dataOffset + compressedSize);
|
|
141
145
|
if (compressionMethod === 0)
|
|
@@ -227,8 +231,13 @@ export async function provisionServer(serverName, options = {}) {
|
|
|
227
231
|
if (process.platform !== 'win32')
|
|
228
232
|
chmodSync(tmpPath, 0o755);
|
|
229
233
|
renameSync(tmpPath, binPath);
|
|
230
|
-
// Completion marker LAST — resolveCachedServer trusts
|
|
231
|
-
|
|
234
|
+
// Completion marker LAST — resolveCachedServer trusts only a marker that
|
|
235
|
+
// matches the final binary's hash and size.
|
|
236
|
+
writeFileSync(`${binPath}.ok`, JSON.stringify({
|
|
237
|
+
assetSha256: asset.sha256,
|
|
238
|
+
binarySha256: sha256(extracted.bytes),
|
|
239
|
+
size: extracted.bytes.length,
|
|
240
|
+
}));
|
|
232
241
|
return { ok: true, path: binPath, source: 'downloaded' };
|
|
233
242
|
}
|
|
234
243
|
finally {
|
package/dist/lsp/types.d.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
export type InitializationOptions = Record<string, unknown>;
|
|
2
|
+
/**
|
|
3
|
+
* Outcome of `waitForReady` — a readiness signal that lets a zero-results
|
|
4
|
+
* semantic query be interpreted honestly:
|
|
5
|
+
* - `progressIdle` — a `$/progress` indexing cycle was seen and drained to
|
|
6
|
+
* idle; an empty answer reflects the fully-indexed project.
|
|
7
|
+
* - `settledFallback`— no `$/progress` was ever seen, only the fixed settle
|
|
8
|
+
* window elapsed; indexing completion is unconfirmed.
|
|
9
|
+
* - `timeout` — `$/progress` was still active when the deadline hit;
|
|
10
|
+
* the server is (as far as we know) still indexing.
|
|
11
|
+
*/
|
|
12
|
+
export type LspReadiness = 'progressIdle' | 'settledFallback' | 'timeout';
|
|
2
13
|
/**
|
|
3
14
|
* Where a language-server executable was resolved from, for honest status
|
|
4
15
|
* reporting. Ordered by the resolution ladder (see docs/context/LSP_GUIDE.md):
|
|
@@ -201,6 +201,12 @@ function validateCommandArgs(command, args) {
|
|
|
201
201
|
};
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
|
+
else {
|
|
205
|
+
const backendError = validateArchiveBackendArgs(command, args);
|
|
206
|
+
if (backendError) {
|
|
207
|
+
return { isValid: false, error: backendError };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
204
210
|
// grep: no per-flag allowlist — only the shared dangerous-pattern scan below
|
|
205
211
|
// applies. grep flags are wide (e.g. -r, -Z, --include) and intentionally
|
|
206
212
|
// left to the pattern scan rather than a strict allowlist.
|
|
@@ -277,6 +283,64 @@ function getGrepPatternPositions(args) {
|
|
|
277
283
|
}
|
|
278
284
|
return positions;
|
|
279
285
|
}
|
|
286
|
+
function isPathLikeArg(value) {
|
|
287
|
+
return value.length > 0 && !value.startsWith('-');
|
|
288
|
+
}
|
|
289
|
+
function exactArgs(args, expected) {
|
|
290
|
+
return args.length === expected.length && args.every((arg, i) => arg === expected[i]);
|
|
291
|
+
}
|
|
292
|
+
function validateArchiveBackendArgs(command, args) {
|
|
293
|
+
switch (command) {
|
|
294
|
+
case 'file':
|
|
295
|
+
return args.length === 3 && exactArgs(args.slice(0, 2), ['--mime-type', '-b']) && isPathLikeArg(args[2])
|
|
296
|
+
? null
|
|
297
|
+
: 'file arguments are restricted to --mime-type -b <path>';
|
|
298
|
+
case 'zcat':
|
|
299
|
+
case 'bzcat':
|
|
300
|
+
case 'zstdcat':
|
|
301
|
+
case 'lz4cat':
|
|
302
|
+
return args.length === 1 && isPathLikeArg(args[0])
|
|
303
|
+
? null
|
|
304
|
+
: `${command} arguments are restricted to <path>`;
|
|
305
|
+
case 'gunzip':
|
|
306
|
+
return args.length === 2 && args[0] === '-c' && isPathLikeArg(args[1])
|
|
307
|
+
? null
|
|
308
|
+
: 'gunzip arguments are restricted to -c <path>';
|
|
309
|
+
case 'xzcat':
|
|
310
|
+
return (args.length === 1 && isPathLikeArg(args[0])) ||
|
|
311
|
+
(args.length === 2 && args[0] === '--format=lzma' && isPathLikeArg(args[1]))
|
|
312
|
+
? null
|
|
313
|
+
: 'xzcat arguments are restricted to <path> or --format=lzma <path>';
|
|
314
|
+
case 'zstd':
|
|
315
|
+
return args.length === 2 && args[0] === '-dcq' && isPathLikeArg(args[1])
|
|
316
|
+
? null
|
|
317
|
+
: 'zstd arguments are restricted to -dcq <path>';
|
|
318
|
+
case 'brotli':
|
|
319
|
+
return args.length === 2 && args[0] === '-dc' && isPathLikeArg(args[1])
|
|
320
|
+
? null
|
|
321
|
+
: 'brotli arguments are restricted to -dc <path>';
|
|
322
|
+
case 'lzfse':
|
|
323
|
+
return args.length === 5 && exactArgs(args.slice(0, 2), ['-decode', '-i']) && isPathLikeArg(args[2]) && args[3] === '-o' && args[4] === '/dev/stdout'
|
|
324
|
+
? null
|
|
325
|
+
: 'lzfse arguments are restricted to -decode -i <path> -o /dev/stdout';
|
|
326
|
+
case 'tar':
|
|
327
|
+
case 'bsdtar':
|
|
328
|
+
return args.length === 4 && args[0] === '-xOf' && isPathLikeArg(args[1]) && args[2] === '--' && args[3].length > 0
|
|
329
|
+
? null
|
|
330
|
+
: `${command} arguments are restricted to -xOf <archive> -- <entry>`;
|
|
331
|
+
case 'unzip':
|
|
332
|
+
return args.length === 3 && args[0] === '-p' && isPathLikeArg(args[1]) && args[2].length > 0
|
|
333
|
+
? null
|
|
334
|
+
: 'unzip arguments are restricted to -p <archive> <entry>';
|
|
335
|
+
case '7z':
|
|
336
|
+
case '7zz':
|
|
337
|
+
return args.length === 6 && exactArgs(args.slice(0, 4), ['e', '-so', '-bd', '--']) && isPathLikeArg(args[4]) && args[5].length > 0
|
|
338
|
+
? null
|
|
339
|
+
: `${command} arguments are restricted to e -so -bd -- <archive> <entry>`;
|
|
340
|
+
default:
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
280
344
|
const FIND_PATTERN_ARGS = new Set([
|
|
281
345
|
'-name',
|
|
282
346
|
'-iname',
|
|
@@ -163,9 +163,10 @@ function validateRecursive(params, depth, ancestorStack) {
|
|
|
163
163
|
hasValidationErrors = true;
|
|
164
164
|
r.warnings.forEach(w => warnings.add(`Invalid nested object in parameter ${key}: ${w}`));
|
|
165
165
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
// Always include sanitized data — tool handlers must not receive undefined
|
|
167
|
+
// for a key that was present in the input, even when the nested object had
|
|
168
|
+
// validation issues. The sanitized partial result is still safer than the raw.
|
|
169
|
+
sanitizedParams[key] = r.sanitizedParams;
|
|
169
170
|
}
|
|
170
171
|
else {
|
|
171
172
|
sanitizedParams[key] = value;
|
|
@@ -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
|
}
|
|
@@ -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);
|
|
@@ -59,22 +62,11 @@ export class PathValidator {
|
|
|
59
62
|
// Non-existent roots are still useful for validating future output paths.
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
|
-
isResolvedPathAllowed(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
try {
|
|
68
|
-
const realRoot = fs.realpathSync(root);
|
|
69
|
-
if (absolutePath === root) {
|
|
70
|
-
return resolvedPath === realRoot;
|
|
71
|
-
}
|
|
72
|
-
return resolvedPath.startsWith(realRoot + path.sep);
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
return false;
|
|
76
|
-
}
|
|
77
|
-
});
|
|
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));
|
|
78
70
|
}
|
|
79
71
|
validate(inputPath) {
|
|
80
72
|
if (!inputPath || inputPath.trim() === '') {
|
|
@@ -224,9 +216,15 @@ export class PathValidator {
|
|
|
224
216
|
this.allowedRoots = [...roots];
|
|
225
217
|
}
|
|
226
218
|
}
|
|
227
|
-
|
|
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 });
|
|
228
223
|
export function resetPathValidator(options) {
|
|
229
|
-
|
|
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);
|
|
230
228
|
pathValidator.replaceAllowedRoots(newValidator.getAllowedRoots());
|
|
231
229
|
return pathValidator;
|
|
232
230
|
}
|