diff --git a/.release-please-manifest.json b/.release-please-manifest.json index bb45dcb8fd..c2a9a56c93 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -2,7 +2,7 @@ "chat-client": "0.1.56", "core/aws-lsp-core": "0.0.22", "server/aws-lsp-antlr4": "0.1.26", - "server/aws-lsp-codewhisperer": "0.0.126", + "server/aws-lsp-codewhisperer": "0.0.127", "server/aws-lsp-json": "0.1.27", "server/aws-lsp-partiql": "0.0.24", "server/aws-lsp-yaml": "0.1.27" diff --git a/app/aws-lsp-codewhisperer-runtimes/src/version.json b/app/aws-lsp-codewhisperer-runtimes/src/version.json index 9fb2d82896..e8fa26028c 100644 --- a/app/aws-lsp-codewhisperer-runtimes/src/version.json +++ b/app/aws-lsp-codewhisperer-runtimes/src/version.json @@ -1,3 +1,3 @@ { - "agenticChat": "1.77.0" + "agenticChat": "1.78.0" } diff --git a/package-lock.json b/package-lock.json index 4aed07ac92..81579468b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30351,7 +30351,7 @@ }, "server/aws-lsp-codewhisperer": { "name": "@aws/lsp-codewhisperer", - "version": "0.0.126", + "version": "0.0.127", "bundleDependencies": [ "@amzn/codewhisperer", "@amzn/codewhisperer-runtime", diff --git a/server/aws-lsp-codewhisperer/CHANGELOG.md b/server/aws-lsp-codewhisperer/CHANGELOG.md index b7c1051c23..19f7b5fd2e 100644 --- a/server/aws-lsp-codewhisperer/CHANGELOG.md +++ b/server/aws-lsp-codewhisperer/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [0.0.127](https://github.com/Amazon-Q-Developer/language-servers/compare/lsp-codewhisperer/v0.0.126...lsp-codewhisperer/v0.0.127) (2026-08-24) + + +### Bug Fixes + +* **amazonq:** cover merged env and headers in MCP consent fingerprint ([#2851](https://github.com/Amazon-Q-Developer/language-servers/issues/2851)) ([#2853](https://github.com/Amazon-Q-Developer/language-servers/issues/2853)) ([529aed4](https://github.com/Amazon-Q-Developer/language-servers/commit/529aed43259503cf71b475fb3496de7bfab25f17)) +* beam - flat-named transformed zips + lightweight discovery + IsLbvPending ([#2849](https://github.com/Amazon-Q-Developer/language-servers/issues/2849)) ([863c5bf](https://github.com/Amazon-Q-Developer/language-servers/commit/863c5bf00a6c30ec7658056a155f1ca1005127e6)) +* surface backend interactive mode in getTransformInfo ([836b756](https://github.com/Amazon-Q-Developer/language-servers/commit/836b756ee2d8a553d9b26cf095e12979141e2367)) + ## [0.0.126](https://github.com/Amazon-Q-Developer/language-servers/compare/lsp-codewhisperer/v0.0.125...lsp-codewhisperer/v0.0.126) (2026-08-20) diff --git a/server/aws-lsp-codewhisperer/package.json b/server/aws-lsp-codewhisperer/package.json index 4c2a195da7..af7b4aaa17 100644 --- a/server/aws-lsp-codewhisperer/package.json +++ b/server/aws-lsp-codewhisperer/package.json @@ -1,6 +1,6 @@ { "name": "@aws/lsp-codewhisperer", - "version": "0.0.126", + "version": "0.0.127", "description": "CodeWhisperer Language Server", "main": "out/index.js", "repository": { diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.test.ts index 79696b5f9d..b638968139 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.test.ts @@ -8,6 +8,8 @@ import * as fs from 'fs' import * as os from 'os' import * as path from 'path' import { + effectiveEnv, + effectiveHeaders, fingerprintServerConfig, fingerprintWorkspace, hasApproval, @@ -74,6 +76,71 @@ describe('mcpConsentStore', () => { const b: MCPServerConfig = { url: 'https://b.example' } expect(fingerprintServerConfig(a)).to.not.equal(fingerprintServerConfig(b)) }) + + // __additionalEnv__ / __additionalHeaders__ are merged into the spawn env/headers, + // so they must be covered by the fingerprint alongside the raw fields — otherwise a + // post-approval config edit to those fields would not re-prompt. + it('differs when __additionalEnv__ is added', () => { + const a: MCPServerConfig = { command: 'npx', args: ['-y', 's'], env: { LOG_LEVEL: 'info' } } + const b: MCPServerConfig = { + ...a, + __additionalEnv__: { EXTRA: '1' }, + } + expect(fingerprintServerConfig(a)).to.not.equal(fingerprintServerConfig(b)) + }) + + it('differs when __additionalEnv__ overrides an existing env value', () => { + const a: MCPServerConfig = { command: 'sh', args: [], env: { FOO: '1' } } + const b: MCPServerConfig = { command: 'sh', args: [], env: { FOO: '1' }, __additionalEnv__: { FOO: '2' } } + expect(fingerprintServerConfig(a)).to.not.equal(fingerprintServerConfig(b)) + }) + + it('differs when headers change', () => { + const a: MCPServerConfig = { url: 'https://a.example', headers: { Authorization: 'Bearer good' } } + const b: MCPServerConfig = { url: 'https://a.example', headers: { Authorization: 'Bearer attacker' } } + expect(fingerprintServerConfig(a)).to.not.equal(fingerprintServerConfig(b)) + }) + + it('differs when __additionalHeaders__ overrides an existing header', () => { + const a: MCPServerConfig = { url: 'https://a.example', headers: { Authorization: 'Bearer good' } } + const b: MCPServerConfig = { + url: 'https://a.example', + headers: { Authorization: 'Bearer good' }, + __additionalHeaders__: { Authorization: 'Bearer attacker' }, + } + expect(fingerprintServerConfig(a)).to.not.equal(fingerprintServerConfig(b)) + }) + + // Consent is about what will execute, not how the config is spelled: two configs + // that spawn the process with the identical effective env share a fingerprint. + it('hashes the merged spawn env, so the same effective env matches either field', () => { + const viaEnv: MCPServerConfig = { command: 'sh', args: [], env: { FOO: '1' } } + const viaAdditional: MCPServerConfig = { command: 'sh', args: [], __additionalEnv__: { FOO: '1' } } + expect(fingerprintServerConfig(viaEnv)).to.equal(fingerprintServerConfig(viaAdditional)) + }) + + it('is stable regardless of __additionalEnv__ key order', () => { + const a: MCPServerConfig = { command: 'sh', args: [], __additionalEnv__: { A: '1', B: '2' } } + const b: MCPServerConfig = { command: 'sh', args: [], __additionalEnv__: { B: '2', A: '1' } } + expect(fingerprintServerConfig(a)).to.equal(fingerprintServerConfig(b)) + }) + }) + + describe('effectiveEnv / effectiveHeaders', () => { + it('merges __additionalEnv__ over env, matching the spawn-time merge', () => { + const cfg: MCPServerConfig = { env: { A: '1', B: '2' }, __additionalEnv__: { B: 'override', C: '3' } } + expect(effectiveEnv(cfg)).to.deep.equal({ A: '1', B: 'override', C: '3' }) + }) + + it('merges __additionalHeaders__ over headers', () => { + const cfg: MCPServerConfig = { headers: { X: '1' }, __additionalHeaders__: { X: '2', Y: '3' } } + expect(effectiveHeaders(cfg)).to.deep.equal({ X: '2', Y: '3' }) + }) + + it('returns an empty object when nothing is set', () => { + expect(effectiveEnv({})).to.deep.equal({}) + expect(effectiveHeaders({})).to.deep.equal({}) + }) }) describe('fingerprintWorkspace', () => { @@ -183,11 +250,34 @@ describe('mcpConsentStore', () => { const storeDir = path.join(tmpHome, '.aws', 'amazonq') fs.mkdirSync(storeDir, { recursive: true }) fs.writeFileSync(path.join(storeDir, 'mcp-approvals.json'), JSON.stringify({ version: 999, approvals: [] })) - // record should still work (overwrites with v1) + // record should still work (overwrites with the current version) await recordApproval(workspace, logger, 'poc', cfg, configPath) expect(await hasApproval(workspace, logger, 'poc', cfg, configPath)).to.be.true }) + // STORE_VERSION 1 -> 2: v1 fingerprints were computed over a narrower field set and + // cannot be trusted to cover the merged env/headers, so they are discarded and the + // user is re-prompted once per workspace-scoped server after upgrade. + it('discards legacy v1 approvals so the user is re-prompted once after upgrade', async () => { + const storeDir = path.join(tmpHome, '.aws', 'amazonq') + fs.mkdirSync(storeDir, { recursive: true }) + fs.writeFileSync( + path.join(storeDir, 'mcp-approvals.json'), + JSON.stringify({ + version: 1, + approvals: [ + { + serverName: 'poc', + fingerprint: fingerprintServerConfig(cfg), + workspaceHash: fingerprintWorkspace(configPath), + approvedAt: new Date().toISOString(), + }, + ], + }) + ) + expect(await hasApproval(workspace, logger, 'poc', cfg, configPath)).to.be.false + }) + it('treats a malformed store as empty', async () => { const storeDir = path.join(tmpHome, '.aws', 'amazonq') fs.mkdirSync(storeDir, { recursive: true }) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.ts index b9230a6655..45a4f252fc 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.ts @@ -9,7 +9,11 @@ import type { Workspace, Logging } from '@aws/language-server-runtimes/server-in import type { MCPServerConfig } from './mcpTypes' const APPROVALS_FILE = 'mcp-approvals.json' -const STORE_VERSION = 1 +// v2: the fingerprint now covers the fully-merged spawn environment and headers +// (see fingerprintServerConfig). Bumping the version discards v1 approvals, which +// were computed over a narrower field set, so every workspace-scoped server is +// re-consented once after upgrade. +const STORE_VERSION = 2 interface Approval { serverName: string @@ -23,17 +27,49 @@ interface ApprovalStore { approvals: Approval[] } +function sortedRecord(rec?: Record): Record { + return rec ? Object.fromEntries(Object.entries(rec).sort(([a], [b]) => a.localeCompare(b))) : {} +} + +/** + * The environment the stdio transport will actually spawn the server with, as far + * as the config controls it. Mirrors the merge in McpManager (`cfg.env` overlaid by + * `cfg.__additionalEnv__`). + * + * `__additionalEnv__` carries workspace/agent-level `env` for registry servers and is + * NOT folded into `cfg.env`, so it must be merged here or it escapes the fingerprint. + */ +export function effectiveEnv(cfg: MCPServerConfig): Record { + return sortedRecord({ ...(cfg.env ?? {}), ...(cfg.__additionalEnv__ ?? {}) }) +} + +/** + * The headers the HTTP/SSE transport will actually send, as far as the config + * controls it. Mirrors the merge in McpManager (`cfg.headers` overlaid by + * `cfg.__additionalHeaders__`). + */ +export function effectiveHeaders(cfg: MCPServerConfig): Record { + return sortedRecord({ ...(cfg.headers ?? {}), ...(cfg.__additionalHeaders__ ?? {}) }) +} + /** * SHA-256 of a canonical JSON form of the server's execution-relevant fields. - * Any change to command/args/env/url yields a new fingerprint, invalidating + * Any change to command/args/env/url/headers yields a new fingerprint, invalidating * prior approvals — so mutation of the config re-prompts. + * + * `env` and `headers` are hashed in their *merged* form (see effectiveEnv / + * effectiveHeaders) so every field that reaches the spawned process is covered by + * consent. Two configs that spawn an identical process share a fingerprint regardless + * of which field supplied a value: consent is about what will execute, not how the + * config is spelled. */ export function fingerprintServerConfig(cfg: MCPServerConfig): string { const canonical = { command: cfg.command ?? null, args: cfg.args ?? [], - env: cfg.env ? Object.fromEntries(Object.entries(cfg.env).sort(([a], [b]) => a.localeCompare(b))) : {}, + env: effectiveEnv(cfg), url: cfg.url ?? null, + headers: effectiveHeaders(cfg), } return 'sha256:' + createHash('sha256').update(JSON.stringify(canonical)).digest('hex') } diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts index 61e7ea75fe..bde82719d7 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts @@ -42,7 +42,14 @@ import { Mutex } from 'async-mutex' import path = require('path') import { URI } from 'vscode-uri' import { MessageType } from '@aws/language-server-runtimes/protocol' -import { hasApproval, recordApproval, removeApproval, fingerprintServerConfig } from './mcpConsentStore' +import { + hasApproval, + recordApproval, + removeApproval, + fingerprintServerConfig, + effectiveEnv, + effectiveHeaders, +} from './mcpConsentStore' import { sanitizeInput } from '../../../../shared/utils' import { ProfileStatusMonitor } from './profileStatusMonitor' import { OAuthClient } from './mcpOauthClient' @@ -440,6 +447,17 @@ export class McpManager { ) if (!approved) { const cmdLine = [cfg.command ?? cfg.url ?? '(none)', ...(cfg.args ?? [])].join(' ').slice(0, 200) + // Surface the environment variables and headers the server will actually be + // launched with. Names only, never values: a config may legitimately hold + // tokens, and this string is shown in a dialog and written to logs. Values are + // covered by the fingerprint, so any value change re-prompts. + const envKeys = Object.keys(effectiveEnv(cfg)) + const headerNames = Object.keys(effectiveHeaders(cfg)) + const envLine = + envKeys.length > 0 + ? `Environment variables: ${envKeys.join(', ').slice(0, 200)}\n` + : `Environment variables: (none)\n` + const headerLine = headerNames.length > 0 ? `Headers: ${headerNames.join(', ').slice(0, 200)}\n` : '' const allowBtn = { title: 'Allow for this server' } const denyBtn = { title: 'Deny' } let choice: { title: string } | null | undefined @@ -451,8 +469,13 @@ export class McpManager { `A workspace configuration file wants to start an MCP server.\n` + `Server: ${serverName}\n` + `Command: ${cmdLine}\n` + + envLine + + headerLine + `Source: ${configPath}\n\n` + - `Running this server executes the above command on your machine. ` + + `Running this server executes the above command on your machine, ` + + `with the environment variables listed above. ` + + `Review them in the configuration file if you are unsure — variables such as ` + + `NODE_OPTIONS can cause additional code to run. ` + `Only allow if you trust the authors of this workspace.\n\n` + `Your choice will be remembered for this workspace. ` + `If you allow, you won't be asked again unless the server configuration changes.`, diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxModels.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxModels.ts index 21622fea61..f010011550 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxModels.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxModels.ts @@ -146,6 +146,10 @@ export interface AtxGetTransformInfoResponse { MissingPackageJsonPath?: string | null DiffApplyFailed?: boolean DiffApplyFailedStepIds?: string[] + // Interactive mode as resolved from the backend job objective (interactive_mode). + // Surfaced so the IDE can restore the correct mode instead of relying on its local + // settings store, which may be missing/stale (e.g. cold restart on another machine). + InteractiveMode?: InteractiveMode } /** diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxTransformHandler.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxTransformHandler.ts index f0e9abfe2e..04de8649da 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxTransformHandler.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxTransformHandler.ts @@ -1770,6 +1770,12 @@ export class ATXTransformHandler { result.DiffApplyFailed = true result.DiffApplyFailedStepIds = diffContext.failedStepIds } + // Surface the backend-resolved interactive mode (from job.objective) on every + // response so the IDE can restore it. Single injection point covers all internal + // return paths. cachedInteractiveMode is populated in _getTransformInfoInternal. + if (result && this.cachedInteractiveMode) { + result.InteractiveMode = this.cachedInteractiveMode + } return result } finally { this._currentDiffContext = null