@octocodeai/octocode-engine 16.6.0 → 16.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +190 -249
  2. package/dist/lsp/client.d.ts +18 -9
  3. package/dist/lsp/client.js +35 -25
  4. package/dist/lsp/config.d.ts +29 -1
  5. package/dist/lsp/config.js +156 -1
  6. package/dist/lsp/ideContext.d.ts +18 -0
  7. package/dist/lsp/ideContext.js +29 -0
  8. package/dist/lsp/index.d.ts +8 -3
  9. package/dist/lsp/index.js +7 -2
  10. package/dist/lsp/lspClientPool.d.ts +2 -0
  11. package/dist/lsp/lspClientPool.js +9 -4
  12. package/dist/lsp/manager.d.ts +11 -0
  13. package/dist/lsp/manager.js +72 -25
  14. package/dist/lsp/native.d.ts +2 -1
  15. package/dist/lsp/platform.d.ts +20 -0
  16. package/dist/lsp/platform.js +64 -0
  17. package/dist/lsp/resolver.js +10 -4
  18. package/dist/lsp/serverDiscovery.d.ts +63 -0
  19. package/dist/lsp/serverDiscovery.js +226 -0
  20. package/dist/lsp/serverManifest.d.ts +71 -0
  21. package/dist/lsp/serverManifest.js +116 -0
  22. package/dist/lsp/serverManifestData.d.ts +2 -0
  23. package/dist/lsp/serverManifestData.js +96 -0
  24. package/dist/lsp/serverProvisioner.d.ts +19 -0
  25. package/dist/lsp/serverProvisioner.js +262 -0
  26. package/dist/lsp/types.d.ts +18 -0
  27. package/dist/security/commandValidator.js +74 -10
  28. package/dist/security/contentSanitizer.js +4 -3
  29. package/dist/security/discoveryFilter.js +10 -0
  30. package/dist/security/filePatterns.js +6 -0
  31. package/dist/security/ignoredPathFilter.js +10 -2
  32. package/dist/security/mask.js +5 -22
  33. package/dist/security/maskUtils.d.ts +6 -0
  34. package/dist/security/maskUtils.js +22 -0
  35. package/dist/security/native.js +5 -22
  36. package/dist/security/pathPatterns.js +5 -0
  37. package/dist/security/pathValidator.js +38 -37
  38. package/dist/security/registry.js +27 -9
  39. package/dist/security/securityConstants.d.ts +1 -1
  40. package/dist/security/securityConstants.js +22 -1
  41. package/dist/security/withSecurityValidation.js +18 -3
  42. package/docs/LSP_SERVER_LIFECYCLE.md +258 -0
  43. package/index.d.ts +8 -2
  44. package/package.json +54 -10
@@ -0,0 +1,116 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { detectPlatformId } from './platform.js';
6
+ import { MANIFEST } from './serverManifestData.js';
7
+ function loadManifest() {
8
+ return MANIFEST;
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
+ }
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. */
35
+ export function provisionMode(env = process.env) {
36
+ const raw = (env.OCTOCODE_LSP_AUTO_INSTALL ?? '').trim().toLowerCase();
37
+ if (raw === 'auto' || raw === 'off')
38
+ return raw;
39
+ return 'prompt';
40
+ }
41
+ /** The manifest entry for a server, keyed by its bare command name. */
42
+ export function manifestServer(serverName) {
43
+ return loadManifest().servers[path.basename(serverName)] ?? null;
44
+ }
45
+ /** Every auto-downloadable server in the manifest (for `lsp-server list`). */
46
+ export function listManifestServers() {
47
+ return Object.entries(loadManifest().servers).map(([name, server]) => ({
48
+ name,
49
+ languageId: server.languageId,
50
+ releaseTag: server.releaseTag,
51
+ }));
52
+ }
53
+ /** Whether the manifest can, in principle, auto-provide this server. */
54
+ export function isAutoDownloadable(serverName) {
55
+ return manifestServer(serverName) != null;
56
+ }
57
+ /**
58
+ * Root of the managed server cache. Defaults to `~/.octocode/lsp` (consistent
59
+ * with the rest of octocode's home), overridable via `OCTOCODE_LSP_CACHE_DIR`
60
+ * for read-only/ephemeral sandbox HOMEs or to point at a pre-baked image path.
61
+ */
62
+ export function managedCacheRoot(env = process.env) {
63
+ const override = env.OCTOCODE_LSP_CACHE_DIR?.trim();
64
+ if (override)
65
+ return path.resolve(override);
66
+ return path.join(homedir(), '.octocode', 'lsp');
67
+ }
68
+ /** Where a provisioned binary lives once installed/extracted. */
69
+ export function cachedServerBinPath(serverName, platformId = detectPlatformId()) {
70
+ const server = manifestServer(serverName);
71
+ const asset = server?.platforms[platformId];
72
+ if (!server || !asset)
73
+ return null;
74
+ return path.join(managedCacheRoot(), path.basename(serverName), server.releaseTag, asset.binName);
75
+ }
76
+ /**
77
+ * If a managed binary is already present AND verified in the cache (downloaded
78
+ * by a prior run, by CI, or pre-baked), return its absolute path. A binary is
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.
83
+ */
84
+ export function resolveCachedServer(serverName, platformId = detectPlatformId()) {
85
+ const binPath = cachedServerBinPath(serverName, platformId);
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)
99
+ return null;
100
+ return sha256File(binPath) === marker.binarySha256 ? binPath : null;
101
+ }
102
+ /** Human-readable provisioning guidance for a server with no resolved binary. */
103
+ export function manifestInstallHint(serverName) {
104
+ const server = manifestServer(serverName);
105
+ if (!server)
106
+ return null;
107
+ const platformId = detectPlatformId();
108
+ const unsupported = server.unsupportedPlatforms?.[platformId];
109
+ if (unsupported)
110
+ return unsupported;
111
+ const mode = provisionMode();
112
+ if (mode === 'off') {
113
+ return `${serverName} can be auto-downloaded from ${server.repo} (${server.releaseTag}). Re-enable with OCTOCODE_LSP_AUTO_INSTALL=prompt (or =auto), or run \`lsp-server install ${serverName}\`.`;
114
+ }
115
+ return `${serverName} (${server.releaseTag}) will be auto-downloaded on first use from ${server.repo}.`;
116
+ }
@@ -0,0 +1,2 @@
1
+ import type { ManifestFile } from './serverManifest.js';
2
+ export declare const MANIFEST: ManifestFile;
@@ -0,0 +1,96 @@
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. 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
+ "version": 1,
4
+ "servers": {
5
+ "clangd": {
6
+ "languageId": "cpp",
7
+ "launchArgs": [],
8
+ "platforms": {
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
+ }
37
+ },
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"
94
+ }
95
+ }
96
+ };
@@ -0,0 +1,19 @@
1
+ import { type PlatformId } from './platform.js';
2
+ import { provisionMode } from './serverManifest.js';
3
+ export interface ProvisionResult {
4
+ ok: boolean;
5
+ path?: string;
6
+ source?: 'already-present' | 'downloaded';
7
+ error?: string;
8
+ }
9
+ /**
10
+ * Provision `serverName` into the managed cache. Idempotent: returns the
11
+ * already-present path when a verified copy exists. Pure-data inputs come from
12
+ * `serverManifest.json`; nothing is executed during install.
13
+ */
14
+ export declare function provisionServer(serverName: string, options?: {
15
+ mode?: ReturnType<typeof provisionMode>;
16
+ signal?: AbortSignal;
17
+ }): Promise<ProvisionResult>;
18
+ /** Remove a server from the managed cache only (never touches external installs). */
19
+ export declare function uninstallServer(serverName: string, platformId?: PlatformId): boolean;
@@ -0,0 +1,262 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { chmodSync, existsSync, mkdirSync, openSync, closeSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { gunzipSync, inflateRawSync } from 'node:zlib';
5
+ import { detectPlatformId } from './platform.js';
6
+ import { cachedServerBinPath, managedCacheRoot, manifestServer, provisionMode, resolveCachedServer, } from './serverManifest.js';
7
+ /**
8
+ * The write half of LSP provisioning — downloads a pinned, verified portable
9
+ * server binary into the managed cache. This is invoked ONLY by the explicit
10
+ * `octocode lsp-server install` path, never lazily during a semantic query
11
+ * (the resolution ladder's L4 stays read-only). Provisioning a server is
12
+ * orthogonal to the runtime contract: when no server is available at query
13
+ * time the tool layer throws and the agent pivots to text search.
14
+ *
15
+ * Security invariants (all enforced here):
16
+ * - opt-in: refuses unless OCTOCODE_LSP_AUTO_INSTALL is prompt|auto
17
+ * - pinned SHA-256 gate: refuses if the manifest sha256 is null
18
+ * - host allowlist on the request URL AND every redirect hop; https only
19
+ * - atomic: download to a temp file in the dest dir, verify, chmod, rename
20
+ * - `.ok` completion marker written last, so a partial install never resolves
21
+ * - per-target lock so editor + CLI don't race the same download
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
+ */
25
+ const ALLOWED_HOSTS = new Set([
26
+ 'github.com',
27
+ // GitHub release assets redirect to a CDN host; both the legacy
28
+ // (objects.) and current (release-assets.) hosts are GitHub-owned.
29
+ 'objects.githubusercontent.com',
30
+ 'release-assets.githubusercontent.com',
31
+ 'releases.hashicorp.com',
32
+ ]);
33
+ const MAX_REDIRECTS = 5;
34
+ const LOCK_STALE_MS = 10 * 60 * 1000;
35
+ function fail(error) {
36
+ return { ok: false, error };
37
+ }
38
+ function hostAllowed(url) {
39
+ try {
40
+ const parsed = new URL(url);
41
+ return parsed.protocol === 'https:' && ALLOWED_HOSTS.has(parsed.hostname);
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ /** Fetch following redirects manually, re-checking the host allowlist each hop. */
48
+ async function fetchAllowlisted(url, signal) {
49
+ let current = url;
50
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
51
+ if (!hostAllowed(current)) {
52
+ // Report only the host — release-asset URLs carry signed query tokens we
53
+ // must not echo into logs/CLI output.
54
+ let host;
55
+ try {
56
+ host = new URL(current).host;
57
+ }
58
+ catch {
59
+ host = '(unparseable url)';
60
+ }
61
+ return { ok: false, error: `Blocked non-allowlisted/insecure host: ${host}` };
62
+ }
63
+ let response;
64
+ try {
65
+ response = await fetch(current, { redirect: 'manual', signal });
66
+ }
67
+ catch (err) {
68
+ return { ok: false, error: `Download failed: ${err instanceof Error ? err.message : String(err)}` };
69
+ }
70
+ if (response.status >= 300 && response.status < 400) {
71
+ const location = response.headers.get('location');
72
+ if (!location)
73
+ return { ok: false, error: 'Redirect without Location header' };
74
+ current = new URL(location, current).toString();
75
+ continue;
76
+ }
77
+ if (!response.ok) {
78
+ return { ok: false, error: `Download failed: HTTP ${response.status}` };
79
+ }
80
+ return { ok: true, body: await response.arrayBuffer() };
81
+ }
82
+ return { ok: false, error: 'Too many redirects' };
83
+ }
84
+ function sha256(buffer) {
85
+ return createHash('sha256').update(buffer).digest('hex');
86
+ }
87
+ /** Decode the downloaded asset into the final executable bytes. */
88
+ function extractBinary(asset, raw) {
89
+ switch (asset.archive) {
90
+ case 'none':
91
+ return { ok: true, bytes: raw };
92
+ case 'gz':
93
+ return { ok: true, bytes: gunzipSync(raw) };
94
+ case 'zip': {
95
+ if (!asset.binPath) {
96
+ return { ok: false, error: 'zip asset is missing binPath in manifest' };
97
+ }
98
+ const extracted = extractFromZip(raw, asset.binPath);
99
+ if (!extracted) {
100
+ return {
101
+ ok: false,
102
+ error: `Could not find '${asset.binPath}' in zip archive — try installing via your package manager.`,
103
+ };
104
+ }
105
+ return { ok: true, bytes: extracted };
106
+ }
107
+ case 'tar.gz':
108
+ case 'tar.xz':
109
+ return {
110
+ ok: false,
111
+ error: `Archive format '${asset.archive}' is not supported; install the server via your package manager.`,
112
+ };
113
+ default:
114
+ return { ok: false, error: `Unknown archive format '${asset.archive}'` };
115
+ }
116
+ }
117
+ /**
118
+ * Minimal ZIP local-file-header parser. Handles stored (method 0) and deflate
119
+ * (method 8) entries — the two methods used by all known LSP server releases.
120
+ * Searches for `targetPath` by exact match or trailing-segment match so both
121
+ * `clangd_22.1.0/bin/clangd` and `./clangd_22.1.0/bin/clangd` resolve.
122
+ */
123
+ function extractFromZip(zipBuffer, targetPath) {
124
+ const LOCAL_HEADER_SIG = 0x04034b50;
125
+ const normalizedTarget = targetPath.replace(/^\.?\//u, '');
126
+ let offset = 0;
127
+ while (offset + 30 < zipBuffer.length) {
128
+ if (zipBuffer.readUInt32LE(offset) !== LOCAL_HEADER_SIG)
129
+ break;
130
+ const compressionMethod = zipBuffer.readUInt16LE(offset + 8);
131
+ const compressedSize = zipBuffer.readUInt32LE(offset + 18);
132
+ const filenameLen = zipBuffer.readUInt16LE(offset + 26);
133
+ const extraLen = zipBuffer.readUInt16LE(offset + 28);
134
+ const filename = zipBuffer
135
+ .subarray(offset + 30, offset + 30 + filenameLen)
136
+ .toString('utf8')
137
+ .replace(/^\.?\//u, '');
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;
143
+ if (filename === normalizedTarget) {
144
+ const compressed = zipBuffer.subarray(dataOffset, dataOffset + compressedSize);
145
+ if (compressionMethod === 0)
146
+ return compressed; // stored
147
+ if (compressionMethod === 8)
148
+ return inflateRawSync(compressed); // deflate
149
+ return null; // unsupported method within zip
150
+ }
151
+ offset = dataOffset + compressedSize;
152
+ }
153
+ return null;
154
+ }
155
+ function acquireLock(lockPath) {
156
+ try {
157
+ closeSync(openSync(lockPath, 'wx'));
158
+ return true;
159
+ }
160
+ catch {
161
+ // Reclaim a stale lock from a crashed/killed installer.
162
+ try {
163
+ if (Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
164
+ rmSync(lockPath, { force: true });
165
+ closeSync(openSync(lockPath, 'wx'));
166
+ return true;
167
+ }
168
+ }
169
+ catch {
170
+ /* fall through */
171
+ }
172
+ return false;
173
+ }
174
+ }
175
+ /**
176
+ * Provision `serverName` into the managed cache. Idempotent: returns the
177
+ * already-present path when a verified copy exists. Pure-data inputs come from
178
+ * `serverManifest.json`; nothing is executed during install.
179
+ */
180
+ export async function provisionServer(serverName, options = {}) {
181
+ const platformId = detectPlatformId();
182
+ const mode = options.mode ?? provisionMode();
183
+ const server = manifestServer(serverName);
184
+ if (!server)
185
+ return fail(`${serverName} is not an auto-downloadable server.`);
186
+ const unsupported = server.unsupportedPlatforms?.[platformId];
187
+ if (unsupported)
188
+ return fail(unsupported);
189
+ const asset = server.platforms[platformId];
190
+ if (!asset) {
191
+ return fail(`No ${serverName} asset for platform ${platformId}.`);
192
+ }
193
+ // Idempotent: a verified copy is already present.
194
+ const existing = resolveCachedServer(serverName, platformId);
195
+ if (existing)
196
+ return { ok: true, path: existing, source: 'already-present' };
197
+ if (mode === 'off') {
198
+ return fail('Auto-install is off. Set OCTOCODE_LSP_AUTO_INSTALL=prompt|auto (or pass --yes) to allow downloading.');
199
+ }
200
+ if (!asset.sha256) {
201
+ return fail(`${serverName} has no pinned sha256 in the manifest yet; refusing to download unverified bytes.`);
202
+ }
203
+ const binPath = cachedServerBinPath(serverName, platformId);
204
+ if (!binPath)
205
+ return fail(`Cannot compute cache path for ${serverName}.`);
206
+ const dir = path.dirname(binPath);
207
+ mkdirSync(dir, { recursive: true });
208
+ const lockPath = path.join(dir, '.lock');
209
+ if (!acquireLock(lockPath)) {
210
+ return fail(`Another install of ${serverName} is in progress (${lockPath}).`);
211
+ }
212
+ try {
213
+ // Re-check after acquiring the lock (another process may have finished).
214
+ const racedWinner = resolveCachedServer(serverName, platformId);
215
+ if (racedWinner)
216
+ return { ok: true, path: racedWinner, source: 'already-present' };
217
+ const fetched = await fetchAllowlisted(asset.url, options.signal);
218
+ if (!fetched.ok)
219
+ return fail(fetched.error);
220
+ const downloaded = Buffer.from(fetched.body);
221
+ const actualSha = sha256(downloaded);
222
+ if (actualSha !== asset.sha256) {
223
+ return fail(`Checksum mismatch for ${serverName}: expected ${asset.sha256}, got ${actualSha}.`);
224
+ }
225
+ const extracted = extractBinary(asset, downloaded);
226
+ if (!extracted.ok)
227
+ return fail(extracted.error);
228
+ // Atomic: write a temp file in the same dir, chmod, then rename into place.
229
+ const tmpPath = `${binPath}.tmp-${process.pid}`;
230
+ writeFileSync(tmpPath, extracted.bytes);
231
+ if (process.platform !== 'win32')
232
+ chmodSync(tmpPath, 0o755);
233
+ renameSync(tmpPath, binPath);
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
+ }));
241
+ return { ok: true, path: binPath, source: 'downloaded' };
242
+ }
243
+ finally {
244
+ rmSync(lockPath, { force: true });
245
+ }
246
+ }
247
+ /** Remove a server from the managed cache only (never touches external installs). */
248
+ export function uninstallServer(serverName, platformId = detectPlatformId()) {
249
+ const binPath = cachedServerBinPath(serverName, platformId);
250
+ if (!binPath)
251
+ return false;
252
+ const serverDir = path.dirname(path.dirname(binPath)); // <root>/<server>
253
+ if (!existsSync(serverDir))
254
+ return false;
255
+ // Guard: only ever delete inside the managed cache root.
256
+ const root = managedCacheRoot();
257
+ if (!path.resolve(serverDir).startsWith(path.resolve(root) + path.sep)) {
258
+ return false;
259
+ }
260
+ rmSync(serverDir, { recursive: true, force: true });
261
+ return true;
262
+ }
@@ -1,4 +1,22 @@
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';
13
+ /**
14
+ * Where a language-server executable was resolved from, for honest status
15
+ * reporting. Ordered by the resolution ladder (see docs/context/LSP_GUIDE.md):
16
+ * explicit override / PATH → bundled JS dep → project-local → ecosystem dir →
17
+ * managed download cache → IDE bridge; `unavailable` when nothing provides it.
18
+ */
19
+ export type LspServerSource = 'path' | 'bundled' | 'project-local' | 'ecosystem' | 'managed-cache' | 'ide-bridge' | 'unavailable';
2
20
  export interface LanguageServerConfig {
3
21
  command: string;
4
22
  args?: string[];
@@ -201,6 +201,15 @@ 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
+ }
210
+ // grep: no per-flag allowlist — only the shared dangerous-pattern scan below
211
+ // applies. grep flags are wide (e.g. -r, -Z, --include) and intentionally
212
+ // left to the pattern scan rather than a strict allowlist.
204
213
  const patternPositions = getPatternArgPositions(command, args);
205
214
  for (let i = 0; i < args.length; i++) {
206
215
  const arg = args[i];
@@ -274,6 +283,64 @@ function getGrepPatternPositions(args) {
274
283
  }
275
284
  return positions;
276
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
+ }
277
344
  const FIND_PATTERN_ARGS = new Set([
278
345
  '-name',
279
346
  '-iname',
@@ -295,16 +362,13 @@ function getFindPatternPositions(args) {
295
362
  return positions;
296
363
  }
297
364
  function getPatternArgPositions(command, args) {
298
- switch (true) {
299
- case isRgCommand(command):
300
- return getRgPatternPositions(args);
301
- case command === 'grep':
302
- return getGrepPatternPositions(args);
303
- case command === 'find':
304
- return getFindPatternPositions(args);
305
- default:
306
- return new Set();
307
- }
365
+ if (isRgCommand(command))
366
+ return getRgPatternPositions(args);
367
+ if (command === 'grep')
368
+ return getGrepPatternPositions(args);
369
+ if (command === 'find')
370
+ return getFindPatternPositions(args);
371
+ return new Set();
308
372
  }
309
373
  function findDisallowedRgFlag(args) {
310
374
  for (let i = 0; i < args.length; 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;
@@ -1,3 +1,10 @@
1
+ // Discovery pruning lists: skip these dirs/files/extensions during tree walks.
2
+ // SYNC NOTE: pathPatterns.ts:IGNORED_PATH_PATTERNS and
3
+ // filePatterns.ts:IGNORED_FILE_PATTERNS overlap these lists (e.g. .git, .aws,
4
+ // .ssh, .docker, .kube, id_rsa, .pem, .key). The two systems have different
5
+ // roles — pathPatterns/filePatterns block *access* (read-time check);
6
+ // discoveryFilter blocks *visibility* (tree-walk pruning). Both must be kept
7
+ // in sync so that adding a sensitive path/file to one is reflected in the other.
1
8
  export const DISCOVERY_IGNORED_FOLDER_NAMES = [
2
9
  '.github',
3
10
  '.git',
@@ -62,6 +69,9 @@ export const DISCOVERY_IGNORED_FOLDER_NAMES = [
62
69
  '.mvn',
63
70
  '.aws',
64
71
  '.gcp',
72
+ '.ssh',
73
+ '.kube',
74
+ '.docker',
65
75
  'fastlane',
66
76
  'DerivedData',
67
77
  'xcuserdata',