Skip to content

Commit 62d50d9

Browse files
leigaoljustinmk3
andauthored
feat(amazonq): Bundle LSP with the extension as fallback. (#7421)
## Problem The LSP start failure because 1. node binary is blocked because of firewall 2. chat UI js file is blocked because of firewall or anti virus 3. lsp js file is broken post download because of security mechanism ## Solution 1. Bundle the JS LSP with the amazonq package. 2. Re-start LSP wth the bundled JS files if and only if downloaded LSP does not work! 3. Use the VS Code vended node to start the bundled LSP. This was tested by 1. Generated the vsix, which is now 20MB. 2. Disconnect from internet, remove local LSP caches 3. Install the new vsix 4. Webview of chat should load. also tested by manually corrupting the aws-lsp-codewhisperer.js Limitations: 1. The indexing library function will not work because it is missing. 2. rg is not in the bundle Ref: aws/aws-toolkit-jetbrains#5772 --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --------- Co-authored-by: Justin M. Keyes <jmkeyes@amazon.com>
1 parent 7e6786b commit 62d50d9

File tree

6 files changed

+223
-7
lines changed

6 files changed

+223
-7
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Feature",
3+
"description": "Launch LSP with bundled artifacts as fallback"
4+
}

packages/amazonq/src/lsp/activation.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55

66
import vscode from 'vscode'
77
import { startLanguageServer } from './client'
8-
import { AmazonQLspInstaller } from './lspInstaller'
9-
import { lspSetupStage, ToolkitError, messages } from 'aws-core-vscode/shared'
8+
import { AmazonQLspInstaller, getBundledResourcePaths } from './lspInstaller'
9+
import { lspSetupStage, ToolkitError, messages, getLogger } from 'aws-core-vscode/shared'
1010

1111
export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
1212
try {
@@ -16,6 +16,15 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
1616
})
1717
} catch (err) {
1818
const e = err as ToolkitError
19-
void messages.showViewLogsMessage(`Failed to launch Amazon Q language server: ${e.message}`)
19+
getLogger('amazonqLsp').warn(`Failed to start downloaded LSP, falling back to bundled LSP: ${e.message}`)
20+
try {
21+
await lspSetupStage('all', async () => {
22+
await lspSetupStage('launch', async () => await startLanguageServer(ctx, getBundledResourcePaths(ctx)))
23+
})
24+
} catch (error) {
25+
void messages.showViewLogsMessage(
26+
`Failed to launch Amazon Q language server: ${(error as ToolkitError).message}`
27+
)
28+
}
2029
}
2130
}

packages/amazonq/src/lsp/chat/webviewProvider.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
AmazonQPromptSettings,
2020
LanguageServerResolver,
2121
amazonqMark,
22+
getLogger,
2223
} from 'aws-core-vscode/shared'
2324
import { AuthUtil, RegionProfile } from 'aws-core-vscode/codewhisperer'
2425
import { featureConfig } from 'aws-core-vscode/amazonq'
@@ -44,9 +45,12 @@ export class AmazonQChatViewProvider implements WebviewViewProvider {
4445
) {
4546
const lspDir = Uri.file(LanguageServerResolver.defaultDir())
4647
const dist = Uri.joinPath(globals.context.extensionUri, 'dist')
47-
48-
const resourcesRoots = [lspDir, dist]
49-
48+
const bundledResources = Uri.joinPath(globals.context.extensionUri, 'resources/language-server')
49+
let resourcesRoots = [lspDir, dist]
50+
if (this.mynahUIPath?.startsWith(globals.context.extensionUri.fsPath)) {
51+
getLogger('amazonqLsp').info(`Using bundled webview resources ${bundledResources.fsPath}`)
52+
resourcesRoots = [bundledResources, dist]
53+
}
5054
/**
5155
* if the mynah chat client is defined, then make sure to add it to the resource roots, otherwise
5256
* it will 401 when trying to load

packages/amazonq/src/lsp/lspInstaller.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6+
import vscode from 'vscode'
67
import { fs, getNodeExecutableName, getRgExecutableName, BaseLspInstaller, ResourcePaths } from 'aws-core-vscode/shared'
78
import path from 'path'
89
import { ExtendedAmazonQLSPConfig, getAmazonQLspConfig } from './config'
@@ -54,3 +55,13 @@ export class AmazonQLspInstaller extends BaseLspInstaller.BaseLspInstaller<
5455

5556
protected override downloadMessageOverride: string | undefined = 'Updating Amazon Q plugin'
5657
}
58+
59+
export function getBundledResourcePaths(ctx: vscode.ExtensionContext): AmazonQResourcePaths {
60+
const assetDirectory = vscode.Uri.joinPath(ctx.extensionUri, 'resources', 'language-server').fsPath
61+
return {
62+
lsp: path.join(assetDirectory, 'servers', 'aws-lsp-codewhisperer.js'),
63+
node: process.execPath,
64+
ripGrep: '',
65+
ui: path.join(assetDirectory, 'clients', 'amazonq-ui.js'),
66+
}
67+
}

scripts/lspArtifact.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import * as https from 'https'
2+
import * as fs from 'fs'
3+
import * as crypto from 'crypto'
4+
import * as path from 'path'
5+
import * as os from 'os'
6+
import AdmZip from 'adm-zip'
7+
8+
interface ManifestContent {
9+
filename: string
10+
url: string
11+
hashes: string[]
12+
bytes: number
13+
}
14+
15+
interface ManifestTarget {
16+
platform: string
17+
arch: string
18+
contents: ManifestContent[]
19+
}
20+
21+
interface ManifestVersion {
22+
serverVersion: string
23+
isDelisted: boolean
24+
targets: ManifestTarget[]
25+
}
26+
27+
interface Manifest {
28+
versions: ManifestVersion[]
29+
}
30+
async function verifyFileHash(filePath: string, expectedHash: string): Promise<boolean> {
31+
return new Promise((resolve, reject) => {
32+
const hash = crypto.createHash('sha384')
33+
const stream = fs.createReadStream(filePath)
34+
35+
stream.on('data', (data) => {
36+
hash.update(data)
37+
})
38+
39+
stream.on('end', () => {
40+
const fileHash = hash.digest('hex')
41+
// Remove 'sha384:' prefix from expected hash if present
42+
const expectedHashValue = expectedHash.replace('sha384:', '')
43+
resolve(fileHash === expectedHashValue)
44+
})
45+
46+
stream.on('error', reject)
47+
})
48+
}
49+
50+
async function ensureDirectoryExists(dirPath: string): Promise<void> {
51+
if (!fs.existsSync(dirPath)) {
52+
await fs.promises.mkdir(dirPath, { recursive: true })
53+
}
54+
}
55+
56+
export async function downloadLanguageServer(): Promise<void> {
57+
const tempDir = path.join(os.tmpdir(), 'amazonq-download-temp')
58+
const resourcesDir = path.join(__dirname, '../packages/amazonq/resources/language-server')
59+
60+
// clear previous cached language server
61+
try {
62+
if (fs.existsSync(resourcesDir)) {
63+
fs.rmdirSync(resourcesDir, { recursive: true })
64+
}
65+
} catch (e) {
66+
throw Error(`Failed to clean up language server ${resourcesDir}`)
67+
}
68+
69+
await ensureDirectoryExists(tempDir)
70+
await ensureDirectoryExists(resourcesDir)
71+
72+
return new Promise((resolve, reject) => {
73+
const manifestUrl = 'https://aws-toolkit-language-servers.amazonaws.com/qAgenticChatServer/0/manifest.json'
74+
75+
https
76+
.get(manifestUrl, (res) => {
77+
let data = ''
78+
79+
res.on('data', (chunk) => {
80+
data += chunk
81+
})
82+
83+
res.on('end', async () => {
84+
try {
85+
const manifest: Manifest = JSON.parse(data)
86+
87+
const latestVersion = manifest.versions
88+
.filter((v) => !v.isDelisted)
89+
.sort((a, b) => b.serverVersion.localeCompare(a.serverVersion))[0]
90+
91+
if (!latestVersion) {
92+
throw new Error('No valid version found in manifest')
93+
}
94+
95+
const darwinArm64Target = latestVersion.targets.find(
96+
(t) => t.platform === 'darwin' && t.arch === 'arm64'
97+
)
98+
99+
if (!darwinArm64Target) {
100+
throw new Error('No darwin arm64 target found')
101+
}
102+
103+
for (const content of darwinArm64Target.contents) {
104+
const fileName = content.filename
105+
const fileUrl = content.url
106+
const expectedHash = content.hashes[0]
107+
const tempFilePath = path.join(tempDir, fileName)
108+
const fileFolderName = content.filename.replace('.zip', '')
109+
110+
console.log(`Downloading ${fileName} from ${fileUrl} ...`)
111+
112+
await new Promise((downloadResolve, downloadReject) => {
113+
https
114+
.get(fileUrl, (fileRes) => {
115+
const fileStream = fs.createWriteStream(tempFilePath)
116+
fileRes.pipe(fileStream)
117+
118+
fileStream.on('finish', () => {
119+
fileStream.close()
120+
downloadResolve(void 0)
121+
})
122+
123+
fileStream.on('error', (err) => {
124+
fs.unlink(tempFilePath, () => {})
125+
downloadReject(err)
126+
})
127+
})
128+
.on('error', (err) => {
129+
fs.unlink(tempFilePath, () => {})
130+
downloadReject(err)
131+
})
132+
})
133+
134+
console.log(`Verifying hash for ${fileName}...`)
135+
const isHashValid = await verifyFileHash(tempFilePath, expectedHash)
136+
137+
if (!isHashValid) {
138+
fs.unlinkSync(tempFilePath)
139+
throw new Error(`Hash verification failed for ${fileName}`)
140+
}
141+
142+
console.log(`Extracting ${fileName}...`)
143+
const zip = new AdmZip(tempFilePath)
144+
zip.extractAllTo(path.join(resourcesDir, fileFolderName), true) // true for overwrite
145+
146+
// Clean up temp file
147+
fs.unlinkSync(tempFilePath)
148+
console.log(`Successfully processed ${fileName}`)
149+
}
150+
151+
// Clean up temp directory
152+
fs.rmdirSync(tempDir)
153+
fs.rmdirSync(path.join(resourcesDir, 'servers', 'indexing'), { recursive: true })
154+
fs.rmdirSync(path.join(resourcesDir, 'servers', 'ripgrep'), { recursive: true })
155+
fs.rmSync(path.join(resourcesDir, 'servers', 'node'))
156+
if (!fs.existsSync(path.join(resourcesDir, 'servers', 'aws-lsp-codewhisperer.js'))) {
157+
throw new Error(`Extracting aws-lsp-codewhisperer.js failure`)
158+
}
159+
if (!fs.existsSync(path.join(resourcesDir, 'clients', 'amazonq-ui.js'))) {
160+
throw new Error(`Extracting amazonq-ui.js failure`)
161+
}
162+
console.log('Download and extraction completed successfully')
163+
resolve()
164+
} catch (err) {
165+
// Clean up temp directory on error
166+
if (fs.existsSync(tempDir)) {
167+
fs.rmdirSync(tempDir, { recursive: true })
168+
}
169+
reject(err)
170+
}
171+
})
172+
})
173+
.on('error', (err) => {
174+
// Clean up temp directory on error
175+
if (fs.existsSync(tempDir)) {
176+
fs.rmdirSync(tempDir, { recursive: true })
177+
}
178+
reject(err)
179+
})
180+
})
181+
}

scripts/package.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import * as child_process from 'child_process' // eslint-disable-line no-restricted-imports
2121
import * as nodefs from 'fs' // eslint-disable-line no-restricted-imports
2222
import * as path from 'path'
23+
import { downloadLanguageServer } from './lspArtifact'
2324

2425
function parseArgs() {
2526
// Invoking this script with argument "foo":
@@ -105,7 +106,7 @@ function getVersionSuffix(feature: string, debug: boolean): string {
105106
return `${debugSuffix}${featureSuffix}${commitSuffix}`
106107
}
107108

108-
function main() {
109+
async function main() {
109110
const args = parseArgs()
110111
// It is expected that this will package from a packages/{subproject} folder.
111112
// There is a base config in packages/
@@ -155,6 +156,12 @@ function main() {
155156
}
156157

157158
nodefs.writeFileSync(packageJsonFile, JSON.stringify(packageJson, undefined, ' '))
159+
160+
// add language server bundle
161+
if (packageJson.name === 'amazon-q-vscode') {
162+
await downloadLanguageServer()
163+
}
164+
158165
child_process.execFileSync(
159166
'vsce',
160167
[

0 commit comments

Comments
 (0)