@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.
@@ -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
- 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,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 = 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
@@ -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,4 +1,5 @@
1
- import { existsSync } from 'node:fs';
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 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,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
- 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
  }