Skip to content

Commit 4830c06

Browse files
author
Bhargava Varadharajan
committed
feat(smsus): deeplink support for SMUS
**Description** PR adds deeplink support for SageMaker Unified Studio. Details: 1. Uses a new URI handler for SMUS to have distinct path for SMUS and SM AI. This helps distinguish between the two to special case behavior and also to avoid older toolkit releases from picking up SMUS deeplinks. 2. For SMUS, we are not able to trigger the refresh flow yet. So we will show an error page and ask user to retry from portal again. 3. Added smus specific telemetry and added optional params that we will get from query params. Since we do not have creds or connection info already, all telemetry data has to come from deeplink. 4. Had to change from vscode default context to Toolkit wrapper of that context to be able to register URI. **Motivation** Support deeplink for smus, improve discoverability of feature. **Testing Done** Unit tests. Tested locally, sharing recording with team offline.
1 parent 7a03794 commit 4830c06

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 Expired',
51+
message:
52+
'Your SageMaker Unified Studio session has expired. Close current SSH window and use the SageMaker Unified Studio portal to connect again.',
53+
code: 'SMUS_SESSION_EXPIRED',
54+
shortMessage: 'Session expired, 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)