@octocodeai/octocode-engine 16.6.2 → 17.0.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.
@@ -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
- if (typeof timer === 'object' && 'unref' in timer) {
72
- timer.unref();
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) {
@@ -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 serverConfig = await getLanguageServerForFile(synthesizeFilePathForKey(key), key.workspaceRoot);
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
- return {
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
  }
@@ -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<void>;
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>;
@@ -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}. ${reason}`);
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 reason = error instanceof Error ? error.message : String(error);
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 readFile(candidate, 'utf-8');
161
+ await access(candidate, constants.R_OK);
156
162
  return candidate;
157
163
  }
158
164
  catch {
@@ -7,11 +7,11 @@ 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 = off (default) | prompt | auto
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
14
- * managed cache `~/.octocode/lsp/<server>/<releaseTag>/<binName>`.
14
+ * managed cache `<octocode-home>/lsp/<server>/<releaseTag>/<binName>`.
15
15
  */
16
16
  export type ArchiveKind = 'none' | 'gz' | 'zip' | 'tar.gz' | 'tar.xz';
17
17
  export interface ManifestAsset {
@@ -51,9 +51,9 @@ export declare function listManifestServers(): Array<{
51
51
  /** Whether the manifest can, in principle, auto-provide this server. */
52
52
  export declare function isAutoDownloadable(serverName: string): boolean;
53
53
  /**
54
- * Root of the managed server cache. Defaults to `~/.octocode/lsp` (consistent
55
- * with the rest of octocode's home), overridable via `OCTOCODE_LSP_CACHE_DIR`
56
- * for read-only/ephemeral sandbox HOMEs or to point at a pre-baked image path.
54
+ * Root of the managed server cache. Defaults to `<octocode-home>/lsp` via
55
+ * @octocodeai/config, overridable via `OCTOCODE_LSP_CACHE_DIR` for
56
+ * read-only/ephemeral sandbox HOMEs or to point at a pre-baked image path.
57
57
  */
58
58
  export declare function managedCacheRoot(env?: NodeJS.ProcessEnv): string;
59
59
  /** Where a provisioned binary lives once installed/extracted. */
@@ -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 exists — a
65
- * half-written binary from an interrupted install has no marker and is ignored,
66
- * so it never resolves as "installed". Read-only always safe to call.
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,11 +1,36 @@
1
- import { existsSync } from 'node:fs';
2
- import { homedir } from 'node:os';
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { getOctocodeHome } from '@octocodeai/config';
4
5
  import { detectPlatformId } from './platform.js';
5
6
  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();
@@ -30,15 +55,15 @@ export function isAutoDownloadable(serverName) {
30
55
  return manifestServer(serverName) != null;
31
56
  }
32
57
  /**
33
- * Root of the managed server cache. Defaults to `~/.octocode/lsp` (consistent
34
- * with the rest of octocode's home), overridable via `OCTOCODE_LSP_CACHE_DIR`
35
- * for read-only/ephemeral sandbox HOMEs or to point at a pre-baked image path.
58
+ * Root of the managed server cache. Defaults to `<octocode-home>/lsp` via
59
+ * @octocodeai/config, overridable via `OCTOCODE_LSP_CACHE_DIR` for
60
+ * read-only/ephemeral sandbox HOMEs or to point at a pre-baked image path.
36
61
  */
37
62
  export function managedCacheRoot(env = process.env) {
38
63
  const override = env.OCTOCODE_LSP_CACHE_DIR?.trim();
39
64
  if (override)
40
65
  return path.resolve(override);
41
- return path.join(homedir(), '.octocode', 'lsp');
66
+ return path.join(getOctocodeHome(env), 'lsp');
42
67
  }
43
68
  /** Where a provisioned binary lives once installed/extracted. */
44
69
  export function cachedServerBinPath(serverName, platformId = detectPlatformId()) {
@@ -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 exists — a
55
- * half-written binary from an interrupted install has no marker and is ignored,
56
- * so it never resolves as "installed". Read-only always safe to call.
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 existsSync(binPath) && existsSync(`${binPath}.ok`) ? binPath : null;
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": { "url": "https://github.com/clangd/clangd/releases/download/22.1.0/clangd-mac-22.1.0.zip", "archive": "zip", "binPath": "clangd_22.1.0/bin/clangd", "binName": "clangd", "sha256": "e31e271fe11f6dcd7cf87ca74be4a12788ff8ce5a0b07762583e335c058e939a" },
26
- "darwin-x64": { "url": "https://github.com/clangd/clangd/releases/download/22.1.0/clangd-mac-22.1.0.zip", "archive": "zip", "binPath": "clangd_22.1.0/bin/clangd", "binName": "clangd", "sha256": "e31e271fe11f6dcd7cf87ca74be4a12788ff8ce5a0b07762583e335c058e939a" },
27
- "linux-x64": { "url": "https://github.com/clangd/clangd/releases/download/22.1.0/clangd-linux-22.1.0.zip", "archive": "zip", "binPath": "clangd_22.1.0/bin/clangd", "binName": "clangd", "sha256": "71eddc5303da9a5bc5e8b509488b5b2c5acf45f20e33b8394e71a12a56d67198" },
28
- "win32-x64": { "url": "https://github.com/clangd/clangd/releases/download/22.1.0/clangd-windows-22.1.0.zip", "archive": "zip", "binPath": "clangd_22.1.0/bin/clangd.exe", "binName": "clangd.exe", "sha256": "c54e57dbff3ccc9e8352367ddb7030ad3f624073ec58c7477424e7919f578572" }
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
- "unsupportedPlatforms": { "linux-arm64": "clangd publishes no linux-arm64 release asset; install via the system package manager." }
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 formats
23
- * needing zip/tar/xz extraction return a clear "install manually" error.
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 the binary only now.
231
- writeFileSync(`${binPath}.ok`, JSON.stringify({ sha256: asset.sha256, size: extracted.bytes.length }));
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 {
@@ -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,9 +201,9 @@ function validateCommandArgs(command, args) {
201
201
  };
202
202
  }
203
203
  }
204
- // grep: no per-flag allowlist — only the shared dangerous-pattern scan below
205
- // applies. grep flags are wide (e.g. -r, -Z, --include) and intentionally
206
- // left to the pattern scan rather than a strict allowlist.
204
+ // grep / ls: no per-flag allowlist — only the shared dangerous-pattern scan
205
+ // below applies. grep flags are wide (e.g. -r, -Z, --include) and
206
+ // intentionally left to the pattern scan rather than a strict allowlist.
207
207
  const patternPositions = getPatternArgPositions(command, args);
208
208
  for (let i = 0; i < args.length; i++) {
209
209
  const arg = args[i];
@@ -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
- else {
167
- sanitizedParams[key] = r.sanitizedParams;
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;
@@ -69,6 +69,9 @@ export const DISCOVERY_IGNORED_FOLDER_NAMES = [
69
69
  '.mvn',
70
70
  '.aws',
71
71
  '.gcp',
72
+ '.ssh',
73
+ '.kube',
74
+ '.docker',
72
75
  'fastlane',
73
76
  'DerivedData',
74
77
  'xcuserdata',
@@ -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
- _compiledPathRegex = new RegExp(all.map(r => stripNamedGroups(r.source)).join('|'));
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
- if (opts.includeHomeDir !== false) {
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(absolutePath, resolvedPath) {
63
- return this.allowedRoots.some(root => {
64
- if (resolvedPath === root || resolvedPath.startsWith(root + path.sep)) {
65
- return true;
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
- export const pathValidator = new PathValidator();
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
- const newValidator = new PathValidator(options);
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
  }
@@ -1,15 +1,33 @@
1
1
  import { normalizeCommandName } from './commandUtils.js';
2
- const REDOS_TIMEOUT_MS = 50;
3
- const REDOS_TEST_INPUT = 'a'.repeat(100);
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
- const start = performance.now();
6
- try {
7
- regex.test(REDOS_TEST_INPUT);
8
- }
9
- catch {
10
- return false;
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 performance.now() - start < REDOS_TIMEOUT_MS;
30
+ return true;
13
31
  }
14
32
  export class SecurityRegistry {
15
33
  _extraSecretPatterns = [];
@@ -1,4 +1,10 @@
1
- export const ALLOWED_COMMANDS = ['rg', 'ls', 'find', 'grep', 'git'];
1
+ export const ALLOWED_COMMANDS = [
2
+ 'rg',
3
+ 'ls',
4
+ 'find',
5
+ 'grep',
6
+ 'git',
7
+ ];
2
8
  export const DANGEROUS_PATTERNS = [
3
9
  /[;&|`$(){}[\]<>]/, // Shell metacharacters
4
10
  /\${/,
@@ -6,7 +6,7 @@ export interface SecurityDepsConfig {
6
6
  export declare function configureSecurity(deps: SecurityDepsConfig): void;
7
7
  export declare function withSecurityValidation<T extends Record<string, unknown>, TAuth = unknown>(toolName: string, toolHandler: (sanitizedArgs: T, authInfo?: TAuth, sessionId?: string) => Promise<ToolResult>, options?: {
8
8
  timeoutMs?: number;
9
- }): (args: unknown, extra: {
9
+ }): (args: unknown, extra?: {
10
10
  authInfo?: TAuth;
11
11
  sessionId?: string;
12
12
  signal?: AbortSignal;