@octocodeai/octocode-engine 16.6.0 → 16.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +190 -249
- package/dist/lsp/client.d.ts +18 -9
- package/dist/lsp/client.js +35 -25
- package/dist/lsp/config.d.ts +29 -1
- package/dist/lsp/config.js +156 -1
- package/dist/lsp/ideContext.d.ts +18 -0
- package/dist/lsp/ideContext.js +29 -0
- package/dist/lsp/index.d.ts +8 -3
- package/dist/lsp/index.js +7 -2
- package/dist/lsp/lspClientPool.d.ts +2 -0
- package/dist/lsp/lspClientPool.js +9 -4
- package/dist/lsp/manager.d.ts +11 -0
- package/dist/lsp/manager.js +72 -25
- package/dist/lsp/native.d.ts +2 -1
- package/dist/lsp/platform.d.ts +20 -0
- package/dist/lsp/platform.js +64 -0
- package/dist/lsp/resolver.js +10 -4
- package/dist/lsp/serverDiscovery.d.ts +63 -0
- package/dist/lsp/serverDiscovery.js +226 -0
- package/dist/lsp/serverManifest.d.ts +71 -0
- package/dist/lsp/serverManifest.js +116 -0
- package/dist/lsp/serverManifestData.d.ts +2 -0
- package/dist/lsp/serverManifestData.js +96 -0
- package/dist/lsp/serverProvisioner.d.ts +19 -0
- package/dist/lsp/serverProvisioner.js +262 -0
- package/dist/lsp/types.d.ts +18 -0
- package/dist/security/commandValidator.js +74 -10
- package/dist/security/contentSanitizer.js +4 -3
- package/dist/security/discoveryFilter.js +10 -0
- package/dist/security/filePatterns.js +6 -0
- package/dist/security/ignoredPathFilter.js +10 -2
- package/dist/security/mask.js +5 -22
- package/dist/security/maskUtils.d.ts +6 -0
- package/dist/security/maskUtils.js +22 -0
- package/dist/security/native.js +5 -22
- package/dist/security/pathPatterns.js +5 -0
- package/dist/security/pathValidator.js +38 -37
- package/dist/security/registry.js +27 -9
- package/dist/security/securityConstants.d.ts +1 -1
- package/dist/security/securityConstants.js +22 -1
- package/dist/security/withSecurityValidation.js +18 -3
- package/docs/LSP_SERVER_LIFECYCLE.md +258 -0
- package/index.d.ts +8 -2
- package/package.json +54 -10
|
@@ -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,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
|
+
}
|
package/dist/lsp/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
// Always include sanitized data — tool handlers must not receive undefined
|
|
167
|
+
// for a key that was present in the input, even when the nested object had
|
|
168
|
+
// validation issues. The sanitized partial result is still safer than the raw.
|
|
169
|
+
sanitizedParams[key] = r.sanitizedParams;
|
|
169
170
|
}
|
|
170
171
|
else {
|
|
171
172
|
sanitizedParams[key] = value;
|
|
@@ -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',
|