Skip to content

Commit 2b2de1e

Browse files
Merge master into feature/LSP-gamma
2 parents 0022a6f + 8d1eb19 commit 2b2de1e

File tree

16 files changed

+646
-62
lines changed

16 files changed

+646
-62
lines changed

packages/core/src/awsService/sagemaker/commands.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,16 @@ export async function deeplinkConnect(
9696
wsUrl: string,
9797
token: string,
9898
domain: string,
99-
appType?: string
99+
appType?: string,
100+
isSMUS: boolean = false
100101
) {
101102
getLogger().debug(
102-
`sm:deeplinkConnect: connectionIdentifier: ${connectionIdentifier} session: ${session} wsUrl: ${wsUrl} token: ${token}`
103+
'sm:deeplinkConnect: connectionIdentifier: %s session: %s wsUrl: %s token: %s isSMUS: %s',
104+
connectionIdentifier,
105+
session,
106+
wsUrl,
107+
token,
108+
isSMUS
103109
)
104110

105111
if (isRemoteWorkspace()) {
@@ -112,7 +118,7 @@ export async function deeplinkConnect(
112118
connectionIdentifier,
113119
ctx.extensionContext,
114120
'sm_dl',
115-
false /* isSMUS */,
121+
isSMUS,
116122
undefined /* node */,
117123
session,
118124
wsUrl,
@@ -130,7 +136,10 @@ export async function deeplinkConnect(
130136
)
131137
} catch (err: any) {
132138
getLogger().error(
133-
`sm:OpenRemoteConnect: Unable to connect to target space with arn: ${connectionIdentifier} error: ${err}`
139+
'sm:OpenRemoteConnect: Unable to connect to target space with arn: %s error: %s isSMUS: %s',
140+
connectionIdentifier,
141+
err,
142+
isSMUS
134143
)
135144

136145
if (![RemoteSessionError.MissingExtension, RemoteSessionError.ExtensionVersionTooLow].includes(err.code)) {

packages/core/src/awsService/sagemaker/constants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,11 @@ export const InstanceTypeNotSelectedMessage = (spaceName: string) => {
4545

4646
export const RemoteAccessRequiredMessage =
4747
'This space requires remote access to be enabled.\nWould you like to restart the space and connect?\nAny unsaved work will be lost.'
48+
49+
export const SmusDeeplinkSessionExpiredError = {
50+
title: 'Session Disconnected',
51+
message:
52+
'Your SageMaker Unified Studio session has been disconnected. Select a local (non-remote) VS Code window and use the SageMaker Unified Studio portal to connect again.',
53+
code: 'SMUS_SESSION_DISCONNECTED',
54+
shortMessage: 'Session disconnected, re-connect from SageMaker Unified Studio portal.',
55+
} as const

packages/core/src/awsService/sagemaker/credentialMapping.ts

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -90,41 +90,51 @@ export async function persistSmusProjectCreds(spaceArn: string, node: SagemakerU
9090
* @param session - SSM session ID.
9191
* @param wsUrl - SSM WebSocket URL.
9292
* @param token - Bearer token for the session.
93+
* @param appType - Application type (e.g., 'jupyterlab', 'codeeditor').
94+
* @param isSMUS - If true, skip refreshUrl construction (SMUS connections cannot refresh).
9395
*/
9496
export async function persistSSMConnection(
9597
spaceArn: string,
9698
domain: string,
9799
session?: string,
98100
wsUrl?: string,
99101
token?: string,
100-
appType?: string
102+
appType?: string,
103+
isSMUS?: boolean
101104
): Promise<void> {
102-
const { region } = parseArn(spaceArn)
103-
const endpoint = DevSettings.instance.get('endpoints', {})['sagemaker'] ?? ''
105+
let refreshUrl: string | undefined
104106

105-
let appSubDomain = 'jupyterlab'
106-
if (appType && appType.toLowerCase() === 'codeeditor') {
107-
appSubDomain = 'code-editor'
108-
}
107+
if (!isSMUS) {
108+
// Construct refreshUrl for SageMaker AI connections
109+
const { region } = parseArn(spaceArn)
110+
const endpoint = DevSettings.instance.get('endpoints', {})['sagemaker'] ?? ''
109111

110-
let envSubdomain: string
112+
let appSubDomain = 'jupyterlab'
113+
if (appType && appType.toLowerCase() === 'codeeditor') {
114+
appSubDomain = 'code-editor'
115+
}
111116

112-
if (endpoint.includes('beta')) {
113-
envSubdomain = 'devo'
114-
} else if (endpoint.includes('gamma')) {
115-
envSubdomain = 'loadtest'
116-
} else {
117-
envSubdomain = 'studio'
118-
}
117+
let envSubdomain: string
119118

120-
// Use the standard AWS domain for 'studio' (prod).
121-
// For non-prod environments, use the obfuscated domain 'asfiovnxocqpcry.com'.
122-
const baseDomain =
123-
envSubdomain === 'studio'
124-
? `studio.${region}.sagemaker.aws`
125-
: `${envSubdomain}.studio.${region}.asfiovnxocqpcry.com`
119+
if (endpoint.includes('beta')) {
120+
envSubdomain = 'devo'
121+
} else if (endpoint.includes('gamma')) {
122+
envSubdomain = 'loadtest'
123+
} else {
124+
envSubdomain = 'studio'
125+
}
126+
127+
// Use the standard AWS domain for 'studio' (prod).
128+
// For non-prod environments, use the obfuscated domain 'asfiovnxocqpcry.com'.
129+
const baseDomain =
130+
envSubdomain === 'studio'
131+
? `studio.${region}.sagemaker.aws`
132+
: `${envSubdomain}.studio.${region}.asfiovnxocqpcry.com`
133+
134+
refreshUrl = `https://studio-${domain}.${baseDomain}/${appSubDomain}`
135+
}
136+
// For SMUS connections, refreshUrl remains undefined
126137

127-
const refreshUrl = `https://studio-${domain}.${baseDomain}/${appSubDomain}`
128138
await setSpaceCredentials(spaceArn, refreshUrl, {
129139
sessionId: session ?? '-',
130140
url: wsUrl ?? '-',
@@ -179,12 +189,12 @@ export async function setSmusSpaceSsoProfile(spaceArn: string, projectId: string
179189
* Stores SSM connection information for a given space, typically from a deep link session.
180190
* This initializes the request as 'fresh' and includes a refresh URL if provided.
181191
* @param spaceArn - The arn of the SageMaker space.
182-
* @param refreshUrl - URL to use for refreshing session tokens.
192+
* @param refreshUrl - URL to use for refreshing session tokens (undefined for SMUS connections).
183193
* @param credentials - The session information used to initiate the connection.
184194
*/
185195
export async function setSpaceCredentials(
186196
spaceArn: string,
187-
refreshUrl: string,
197+
refreshUrl: string | undefined,
188198
credentials: SsmConnectionInfo
189199
): Promise<void> {
190200
const data = await loadMappings()

packages/core/src/awsService/sagemaker/detached-server/routes/getSessionAsync.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { IncomingMessage, ServerResponse } from 'http'
99
import url from 'url'
1010
import { SessionStore } from '../sessionStore'
1111
import { open, parseArn, readServerInfo } from '../utils'
12+
import { openErrorPage } from '../errorPage'
13+
import { SmusDeeplinkSessionExpiredError } from '../../constants'
1214

1315
export async function handleGetSessionAsync(req: IncomingMessage, res: ServerResponse): Promise<void> {
1416
const parsedUrl = url.parse(req.url || '', true)
@@ -46,8 +48,34 @@ export async function handleGetSessionAsync(req: IncomingMessage, res: ServerRes
4648
res.end()
4749
return
4850
} else if (status === 'not-started') {
49-
const serverInfo = await readServerInfo()
5051
const refreshUrl = await store.getRefreshUrl(connectionIdentifier)
52+
53+
// Check if this is a SMUS connection (no refreshUrl available)
54+
if (refreshUrl === undefined) {
55+
console.log(`SMUS session expired for connection: ${connectionIdentifier}`)
56+
57+
// Clean up the expired connection entry
58+
try {
59+
await store.cleanupExpiredConnection(connectionIdentifier)
60+
console.log(`Cleaned up expired connection: ${connectionIdentifier}`)
61+
} catch (cleanupErr) {
62+
console.error(`Failed to cleanup expired connection: ${cleanupErr}`)
63+
// Continue with error response even if cleanup fails
64+
}
65+
66+
await openErrorPage(SmusDeeplinkSessionExpiredError.title, SmusDeeplinkSessionExpiredError.message)
67+
res.writeHead(400, { 'Content-Type': 'application/json' })
68+
res.end(
69+
JSON.stringify({
70+
error: SmusDeeplinkSessionExpiredError.code,
71+
message: SmusDeeplinkSessionExpiredError.shortMessage,
72+
})
73+
)
74+
return
75+
}
76+
77+
// Continue with existing SageMaker AI refresh flow
78+
const serverInfo = await readServerInfo()
5179
const { spaceName } = parseArn(connectionIdentifier)
5280

5381
const url = `${refreshUrl}/${encodeURIComponent(spaceName)}?remote_access_token_refresh=true&reconnect_identifier=${encodeURIComponent(

packages/core/src/awsService/sagemaker/detached-server/sessionStore.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { readMapping, writeMapping } from './utils'
99
export type SessionStatus = 'pending' | 'fresh' | 'consumed' | 'not-started'
1010

1111
export class SessionStore {
12-
async getRefreshUrl(connectionId: string) {
12+
async getRefreshUrl(connectionId: string): Promise<string | undefined> {
1313
const mapping = await readMapping()
1414

1515
if (!mapping.deepLink) {
@@ -21,10 +21,6 @@ export class SessionStore {
2121
throw new Error(`No mapping found for connectionId: "${connectionId}"`)
2222
}
2323

24-
if (!entry.refreshUrl) {
25-
throw new Error(`No refreshUrl found for connectionId: "${connectionId}"`)
26-
}
27-
2824
return entry.refreshUrl
2925
}
3026

@@ -113,6 +109,20 @@ export class SessionStore {
113109
await writeMapping(mapping)
114110
}
115111

112+
async cleanupExpiredConnection(connectionId: string) {
113+
const mapping = await readMapping()
114+
115+
if (!mapping.deepLink) {
116+
throw new Error('No deepLink mapping found')
117+
}
118+
119+
// Remove the entire connection entry for the expired space
120+
if (mapping.deepLink[connectionId]) {
121+
delete mapping.deepLink[connectionId]
122+
await writeMapping(mapping)
123+
}
124+
}
125+
116126
async setSession(connectionId: string, requestId: string, ssmConnectionInfo: SsmConnectionInfo) {
117127
const mapping = await readMapping()
118128

packages/core/src/awsService/sagemaker/model.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export async function prepareDevEnvConnection(
8585
await persistSmusProjectCreds(spaceArn, node as SagemakerUnifiedStudioSpaceNode)
8686
}
8787
} else if (connectionType === 'sm_dl') {
88-
await persistSSMConnection(spaceArn, domain ?? '', session, wsUrl, token, appType)
88+
await persistSSMConnection(spaceArn, domain ?? '', session, wsUrl, token, appType, isSMUS)
8989
}
9090

9191
await startLocalServer(ctx)

packages/core/src/extensionNode.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ export async function activate(context: vscode.ExtensionContext) {
199199
await handleAmazonQInstall()
200200
}
201201

202-
await activateSageMakerUnifiedStudio(context)
202+
await activateSageMakerUnifiedStudio(extContext)
203203

204204
await activateApplicationComposer(context)
205205
await activateThreatComposerEditor(context)

packages/core/src/sagemakerunifiedstudio/activation.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,25 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import * as vscode from 'vscode'
76
import { activate as activateConnectionMagicsSelector } from './connectionMagicsSelector/activation'
87
import { activate as activateExplorer } from './explorer/activation'
98
import { isSageMaker } from '../shared/extensionUtilities'
109
import { initializeResourceMetadata } from './shared/utils/resourceMetadataUtils'
1110
import { setContext } from '../shared/vscode/setContext'
1211
import { SmusUtils } from './shared/smusUtils'
12+
import * as smusUriHandlers from './uriHandlers'
13+
import { ExtContext } from '../shared/extensions'
1314

14-
export async function activate(extensionContext: vscode.ExtensionContext): Promise<void> {
15+
export async function activate(ctx: ExtContext): Promise<void> {
1516
// Only run when environment is a SageMaker Unified Studio space
1617
if (isSageMaker('SMUS') || isSageMaker('SMUS-SPACE-REMOTE-ACCESS')) {
1718
await initializeResourceMetadata()
1819
// Setting context before any getContext calls to avoid potential race conditions.
1920
await setContext('aws.smus.inSmusSpaceEnvironment', SmusUtils.isInSmusSpaceEnvironment())
20-
await activateConnectionMagicsSelector(extensionContext)
21+
await activateConnectionMagicsSelector(ctx.extensionContext)
2122
}
22-
await activateExplorer(extensionContext)
23+
await activateExplorer(ctx.extensionContext)
24+
25+
// Register SMUS URI handler for deeplink connections
26+
ctx.extensionContext.subscriptions.push(smusUriHandlers.register(ctx))
2327
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import * as vscode from 'vscode'
7+
import { SearchParams } from '../shared/vscode/uriHandler'
8+
import { ExtContext } from '../shared/extensions'
9+
import { deeplinkConnect } from '../awsService/sagemaker/commands'
10+
import { telemetry } from '../shared/telemetry/telemetry'
11+
/**
12+
* Registers the SMUS deeplink URI handler at path `/connect/smus`.
13+
*
14+
* This handler processes deeplink URLs from the SageMaker Unified Studio console
15+
* to establish remote connections to SMUS spaces.
16+
*
17+
* @param ctx Extension context containing the URI handler
18+
* @returns Disposable for cleanup
19+
*/
20+
export function register(ctx: ExtContext) {
21+
async function connectHandler(params: ReturnType<typeof parseConnectParams>) {
22+
await telemetry.smus_deeplinkConnect.run(async (span) => {
23+
span.record(extractTelemetryMetadata(params))
24+
25+
// WORKAROUND: The ws_url from the startSession API call contains a query parameter
26+
// 'cell-number' within itself. When the entire deeplink URL is processed by the URI
27+
// handler, 'cell-number' is parsed as a standalone query parameter at the top level
28+
// instead of remaining part of the ws_url. This causes the ws_url to lose the
29+
// cell-number context it needs. To fix this, we manually re-append the cell-number
30+
// query parameter back to the ws_url to restore the original intended URL structure.
31+
await deeplinkConnect(
32+
ctx,
33+
params.connection_identifier,
34+
params.session,
35+
`${params.ws_url}&cell-number=${params['cell-number']}`, // Re-append cell-number to ws_url
36+
params.token,
37+
params.domain,
38+
params.app_type,
39+
true // isSMUS=true for SMUS connections
40+
)
41+
})
42+
}
43+
44+
return vscode.Disposable.from(ctx.uriHandler.onPath('/connect/smus', connectHandler, parseConnectParams))
45+
}
46+
47+
/**
48+
* Parses and validates SMUS deeplink URI parameters.
49+
*
50+
* Required parameters:
51+
* - connection_identifier: Space ARN identifying the SMUS space
52+
* - domain: Domain ID for the SMUS space (SM AI side)
53+
* - user_profile: User profile name
54+
* - session: SSM session ID
55+
* - ws_url: WebSocket URL for SSM connection (originally contains cell-number as a query param)
56+
* - cell-number: extracted from ws_url during URI parsing
57+
* - token: Authentication token
58+
*
59+
* Optional parameters:
60+
* - app_type: Application type (e.g., JupyterLab, CodeEditor)
61+
* - smus_domain_id: SMUS domain identifier
62+
* - smus_domain_account_id: SMUS domain account ID
63+
* - smus_project_id: SMUS project identifier
64+
* - smus_domain_region: SMUS domain region
65+
*
66+
* Note: The ws_url from startSession API originally includes cell-number as a query parameter.
67+
* However, when the deeplink URL is processed, the URI handler extracts cell-number as a
68+
* separate top-level parameter. This is why we need to re-append it in the connectHandler.
69+
*
70+
* @param query URI query parameters
71+
* @returns Parsed parameters object
72+
* @throws Error if required parameters are missing
73+
*/
74+
export function parseConnectParams(query: SearchParams) {
75+
const requiredParams = query.getFromKeysOrThrow(
76+
'connection_identifier',
77+
'domain',
78+
'user_profile',
79+
'session',
80+
'ws_url',
81+
'cell-number',
82+
'token'
83+
)
84+
const optionalParams = query.getFromKeys(
85+
'app_type',
86+
'smus_domain_id',
87+
'smus_domain_account_id',
88+
'smus_project_id',
89+
'smus_domain_region'
90+
)
91+
92+
return { ...requiredParams, ...optionalParams }
93+
}
94+
95+
/**
96+
* Extracts telemetry metadata from URI parameters and space ARN.
97+
*
98+
* @param params Parsed URI parameters
99+
* @returns Telemetry metadata object
100+
*/
101+
function extractTelemetryMetadata(params: ReturnType<typeof parseConnectParams>) {
102+
// Extract metadata from space ARN
103+
// ARN format: arn:aws:sagemaker:region:account-id:space/domain-id/space-name
104+
const arnParts = params.connection_identifier.split(':')
105+
const resourceParts = arnParts[5]?.split('/') // Gets "space/domain-id/space-name"
106+
107+
const projectRegion = arnParts[3] // region from ARN
108+
const projectAccountId = arnParts[4] // account-id from ARN
109+
const domainIdFromArn = resourceParts?.[1] // domain-id from ARN
110+
const spaceName = resourceParts?.[2] // space-name from ARN
111+
112+
return {
113+
smusDomainId: params.smus_domain_id,
114+
smusDomainAccountId: params.smus_domain_account_id,
115+
smusProjectId: params.smus_project_id,
116+
smusDomainRegion: params.smus_domain_region,
117+
smusProjectRegion: projectRegion,
118+
smusProjectAccountId: projectAccountId,
119+
smusSpaceKey: domainIdFromArn && spaceName ? `${domainIdFromArn}/${spaceName}` : undefined,
120+
}
121+
}

0 commit comments

Comments
 (0)