Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
public enum AmazonQViewType {

TOOLKIT_LOGIN_VIEW, CHAT_VIEW, DEPENDENCY_MISSING_VIEW, RE_AUTHENTICATE_VIEW, CHAT_ASSET_MISSING_VIEW,
LSP_STARTUP_FAILED_VIEW
LSP_STARTUP_FAILED_VIEW, Q_DEV_ACCESS_BLOCKED_VIEW

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.eclipse.amazonq.broker.events;

/**
* Whether Amazon Q Developer has refused this identity at sign-in.
*
* <p>Reacting to the refusal signs the user out, which on its own would route to the ordinary login
* view and lose the explanation. This state is therefore resolved ahead of the logged-out state by
* {@code ViewRouter}, so the user lands on a screen that says what happened.
*/
public enum QDevAccessBlockedState {
NOT_BLOCKED, BLOCKED
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@

public record ViewRouterPluginState(AuthState authState, AmazonQLspState lspState, BrowserCompatibilityState browserCompatibilityState,
ChatWebViewAssetState chatWebViewAssetState, ToolkitLoginWebViewAssetState toolkitLoginWebViewAssetState,
QDeveloperProfileState qDeveloperProfileState) {
QDeveloperProfileState qDeveloperProfileState, QDevAccessBlockedState qDevAccessBlockedState) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.SsoTokenChangedParams;
import software.aws.toolkits.eclipse.amazonq.lsp.model.ConnectionMetadata;
import software.aws.toolkits.eclipse.amazonq.lsp.model.OpenFileDiffParams;
import software.aws.toolkits.eclipse.amazonq.lsp.model.ShowNotificationParams;

public interface AmazonQLspClient extends LanguageClient {

Expand Down Expand Up @@ -61,6 +62,9 @@ public interface AmazonQLspClient extends LanguageClient {
@JsonNotification("aws/didCreateDirectory")
void didCreateDirectory(Object params);

@JsonNotification("aws/window/showNotification")
void showNotification(ShowNotificationParams params);

@JsonNotification("aws/chat/sendPinnedContext")
void sendPinnedContext(Object params);
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@
import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.SsoTokenChangedParams;
import software.aws.toolkits.eclipse.amazonq.lsp.model.ConnectionMetadata;
import software.aws.toolkits.eclipse.amazonq.lsp.model.OpenFileDiffParams;
import software.aws.toolkits.eclipse.amazonq.lsp.model.ShowNotificationParams;
import software.aws.toolkits.eclipse.amazonq.broker.events.QDevAccessBlockedState;
import software.aws.toolkits.eclipse.amazonq.lsp.model.OpenTabUiResponse;
import software.aws.toolkits.eclipse.amazonq.lsp.model.SsoProfileData;
import software.aws.toolkits.eclipse.amazonq.lsp.model.TelemetryEvent;
Expand Down Expand Up @@ -257,6 +259,37 @@ public final void ssoTokenChanged(final SsoTokenChangedParams params) {
}
}

/**
* Handles the language server's generic notification channel. Today the only notification this
* plugin acts on is the report that Amazon Q Developer has refused this identity at sign-in.
*
* <p>Reacting means signing the user out and routing to an explanation. Sign-out happens here
* rather than in the view because the session is already useless: every Q request from this
* identity is refused, so leaving the user signed in would show a working-looking IDE that
* silently does nothing.
*
* <p>Never throws. This runs on the LSP message thread and is shared by every future
* notification, so a failure to classify one must not take the channel down.
*/
@Override
public final void showNotification(final ShowNotificationParams params) {
try {
if (!QDevAccessBlockedNotification.isAccessBlocked(params)) {
return;
}

String message = params.content() == null ? null : params.content().text();
Activator.getLogger().info("Amazon Q Developer access is blocked for this identity: " + message);

// Publish before signing out. Sign-out makes the router re-evaluate, and the blocked
// state has to be in place by then or the router resolves the ordinary login view.
Activator.getEventBroker().post(QDevAccessBlockedState.class, QDevAccessBlockedState.BLOCKED);
Activator.getLoginService().logout();
} catch (Exception e) {
Activator.getLogger().error("Failed to handle showNotification", e);
}
}

@Override
public final void sendContextCommands(final Object params) {
var command = ChatUIInboundCommand.createCommand("aws/chat/sendContextCommands", params);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ private Map<String, Object> getInitializationOptions(final ClientMetadata metada
awsClientCapabilities.put("q", qOptions);
Map<String, Object> window = new HashMap<>();
window.put("showSaveFileDialog", true);
// Required. The runtime builds no notification router unless the client asks for one, and
// then drops aws/window/showNotification silently -- no error, no log.
window.put("notifications", true);
awsClientCapabilities.put("window", window);
awsInitOptions.put("awsClientCapabilities", awsClientCapabilities);
initOptions.put("aws", awsInitOptions);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.eclipse.amazonq.lsp;

import java.nio.charset.StandardCharsets;
import java.util.Base64;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import software.aws.toolkits.eclipse.amazonq.lsp.model.ShowNotificationParams;
import software.aws.toolkits.eclipse.amazonq.util.ObjectMapperFactory;

/**
* Recognises the notification the language server raises when Amazon Q Developer refuses an identity
* at sign-in.
*
* <p>Only the language server ever observes the refusal. The service gates on the User-Agent of the
* shared language server, so the plugin's own SDK calls are allowed unconditionally -- there is no
* client-side signal to classify. The server reports it over the existing notification channel and
* this class decides whether a given notification is that report.
*
* <p>Identification is by id, never by title. The runtime's router rewrites the declared id into
* base64 of {@code {"serverName":...,"id":...}}, so the raw id never reaches us and a title match
* would sign out a working user the first time an unrelated error reused the same title.
*/
public final class QDevAccessBlockedNotification {

/** Id declared by the server for this notification, found inside the routed envelope. */
private static final String BLOCKED_NOTIFICATION_ID = "qDevPluginAccessBlocked";

private static final ObjectMapper OBJECT_MAPPER = ObjectMapperFactory.getInstance();

private QDevAccessBlockedNotification() {
// utility class
}

/**
* @return true when the given notification reports that this identity is blocked from Amazon Q
* Developer. Never throws: an unrecognised or malformed notification is simply not a
* match, because failing to classify one must not break the notification channel for
* every other message that uses it.
*/
public static boolean isAccessBlocked(final ShowNotificationParams params) {
if (params == null) {
return false;
}
return BLOCKED_NOTIFICATION_ID.equals(resolveId(params.id()));
}

/**
* Resolves the id the server declared. Accepts the routed form, base64 of
* {@code {"serverName":...,"id":...}}, and falls back to the raw value so that a server or
* runtime that does not wrap the id still matches.
*/
private static String resolveId(final String id) {
if (id == null || id.isBlank()) {
return null;
}
try {
String decoded = new String(Base64.getDecoder().decode(id), StandardCharsets.UTF_8);
JsonNode node = OBJECT_MAPPER.readTree(decoded);
JsonNode inner = node.get("id");
if (inner != null && inner.isTextual()) {
return inner.asText();
}
} catch (Exception e) {
// Not a routed envelope. Fall through and treat the value as a plain id.
}
return id;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.eclipse.amazonq.lsp.model;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

/**
* Parameters of the {@code aws/window/showNotification} notification, the language server's generic
* channel for surfacing a message to the user.
*
* <p>The {@code id} does not arrive as the server declared it. The runtime's router rewrites it into
* base64 of {@code {"serverName":...,"id":...}} so that a follow-up action can be routed back to the
* server that raised it. Callers must therefore decode the envelope and compare the inner id rather
* than this field, and must never key behaviour off {@code content.title} -- titles are shared
* between unrelated notifications, so matching on one would fire this handler for the wrong message.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ShowNotificationParams(String id, String type, NotificationContent content) {

@JsonIgnoreProperties(ignoreUnknown = true)
public record NotificationContent(String title, String text) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ AmazonQViewType.DEPENDENCY_MISSING_VIEW, new DependencyMissingView(),
AmazonQViewType.RE_AUTHENTICATE_VIEW, new ReauthenticateView(),
AmazonQViewType.LSP_STARTUP_FAILED_VIEW, new LspStartUpFailedView(),
AmazonQViewType.CHAT_VIEW, new AmazonQChatWebview(),
AmazonQViewType.TOOLKIT_LOGIN_VIEW, new ToolkitLoginWebview());
AmazonQViewType.TOOLKIT_LOGIN_VIEW, new ToolkitLoginWebview(),
AmazonQViewType.Q_DEV_ACCESS_BLOCKED_VIEW, new QDevAccessBlockedView());
}

public AmazonQViewContainer() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.eclipse.amazonq.views;

import org.eclipse.swt.SWT;
import org.eclipse.swt.events.SelectionAdapter;
import org.eclipse.swt.events.SelectionEvent;
import org.eclipse.swt.events.SelectionListener;
import org.eclipse.swt.layout.GridData;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Link;

import software.aws.toolkits.eclipse.amazonq.broker.events.QDevAccessBlockedState;
import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
import software.aws.toolkits.eclipse.amazonq.util.PluginUtils;

/**
* Shown when Amazon Q Developer refuses this identity at sign-in.
*
* <p>Amazon Q Developer stopped accepting new Builder ID accounts. Such an account signs in
* successfully -- sign-in is OIDC and is never gated -- and then finds Q silently non-functional,
* with the service's refusal arriving as if it were a chat reply. This view replaces that dead end
* with an explanation, a pointer to Kiro, and a route back to sign-in for anyone whose Builder ID
* predates the cutoff.
*
* <p>The dates and URLs below are product copy taken from the public announcement, not values
* reported by the service. The service's own message is deliberately not displayed: it is a single
* sentence written for an API consumer, and it does not say what the user should do next.
*/
public final class QDevAccessBlockedView extends CallToActionView {

public static final String ID = "software.aws.toolkits.eclipse.amazonq.views.QDevAccessBlockedView";

private static final String ICON_PATH = "icons/AmazonQ64.png";
private static final String HEADER_LABEL = "New sign-ups are no longer available";
private static final String SIGNUP_CUTOFF_DATE = "May 15, 2026";
private static final String END_OF_SUPPORT_DATE = "April 30, 2027";
private static final String DETAIL_MESSAGE = "Amazon Q Developer stopped accepting new accounts as of "
+ SIGNUP_CUTOFF_DATE + ". Amazon Q Developer IDE plugins are reaching end of support on "
+ END_OF_SUPPORT_DATE + "."
+ System.lineSeparator() + System.lineSeparator()
+ "Kiro includes all the AI coding features from Q Developer, plus spec-driven development and more."
+ System.lineSeparator() + System.lineSeparator()
+ "If your Builder ID was created before " + SIGNUP_CUTOFF_DATE
+ ", you can still sign in -- only newly created accounts are blocked.";
private static final String BUTTON_LABEL = "Get started with Kiro";
private static final String LINK_LABEL = "Try a different login method";

private static final String KIRO_URL = "https://kiro.dev";

@Override
protected String getIconPath() {
return ICON_PATH;
}

@Override
protected String getHeaderLabel() {
return HEADER_LABEL;
}

@Override
protected String getDetailMessage() {
return DETAIL_MESSAGE;
}

@Override
protected String getButtonLabel() {
return BUTTON_LABEL;
}

@Override
protected SelectionListener getButtonHandler() {
return new SelectionAdapter() {
@Override
public void widgetSelected(final SelectionEvent e) {
PluginUtils.openWebpage(KIRO_URL);
}
};
}

@Override
protected void setupButtonFooterContent(final Composite composite) {
Link hyperlink = new Link(composite, SWT.NONE);
hyperlink.setText("<a>" + LINK_LABEL + "</a>");
hyperlink.setLayoutData(new GridData(SWT.CENTER, SWT.CENTER, true, false));
hyperlink.addSelectionListener(new SelectionAdapter() {
@Override
public void widgetSelected(final SelectionEvent e) {
/*
* Clearing the state is what returns the user to sign-in: reacting to the refusal
* already signed them out, so the router resolves the logged-out state as soon as
* this view stops taking priority. Signing out again here would be a no-op.
*/
Activator.getEventBroker().post(QDevAccessBlockedState.class, QDevAccessBlockedState.NOT_BLOCKED);
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import software.aws.toolkits.eclipse.amazonq.broker.events.AmazonQViewType;
import software.aws.toolkits.eclipse.amazonq.broker.events.BrowserCompatibilityState;
import software.aws.toolkits.eclipse.amazonq.broker.events.ChatWebViewAssetState;
import software.aws.toolkits.eclipse.amazonq.broker.events.QDevAccessBlockedState;
import software.aws.toolkits.eclipse.amazonq.broker.events.QDeveloperProfileState;
import software.aws.toolkits.eclipse.amazonq.broker.events.ToolkitLoginWebViewAssetState;
import software.aws.toolkits.eclipse.amazonq.broker.events.ViewRouterPluginState;
Expand Down Expand Up @@ -69,6 +70,11 @@ private ViewRouter(final Builder builder) {
builder.qDeveloperProfileStateObservable = Activator.getEventBroker()
.ofObservable(QDeveloperProfileState.class);
}

if (builder.qDevAccessBlockedStateObservable == null) {
builder.qDevAccessBlockedStateObservable = Activator.getEventBroker()
.ofObservable(QDevAccessBlockedState.class);
}
/**
* Combines all state observables into a single stream that emits a new PluginState
* whenever any individual state changes. The combined stream:
Expand All @@ -78,7 +84,7 @@ private ViewRouter(final Builder builder) {
Observable.combineLatest(builder.authStateObservable, builder.lspStateObservable,
builder.browserCompatibilityStateObservable, builder.chatWebViewAssetStateObservable,
builder.toolkitLoginWebViewAssetStateObservable, builder.qDeveloperProfileStateObservable,
ViewRouterPluginState::new).observeOn(Schedulers.computation()).subscribe(this::onEvent);
builder.qDevAccessBlockedStateObservable, ViewRouterPluginState::new).observeOn(Schedulers.computation()).subscribe(this::onEvent);
}

public static Builder builder() {
Expand Down Expand Up @@ -117,6 +123,13 @@ private void refreshActiveView(final ViewRouterPluginState pluginState) {
} else if (pluginState.chatWebViewAssetState() == ChatWebViewAssetState.DEPENDENCY_MISSING
|| pluginState.toolkitLoginWebViewAssetState() == ToolkitLoginWebViewAssetState.DEPENDENCY_MISSING) {
newActiveView = AmazonQViewType.CHAT_ASSET_MISSING_VIEW;
} else if (pluginState.qDevAccessBlockedState() == QDevAccessBlockedState.BLOCKED) {
/*
* Resolved ahead of the logged-out state on purpose. Reacting to the refusal signs the
* user out, so this state and the logged-out state are always true together; checking
* logged out first would show the ordinary login view and lose the explanation.
*/
newActiveView = AmazonQViewType.Q_DEV_ACCESS_BLOCKED_VIEW;
} else if (pluginState.authState().isLoggedOut()) {
newActiveView = AmazonQViewType.TOOLKIT_LOGIN_VIEW;
} else if (pluginState.authState().isExpired()) {
Expand Down Expand Up @@ -160,6 +173,7 @@ public static final class Builder {
private Observable<ChatWebViewAssetState> chatWebViewAssetStateObservable;
private Observable<ToolkitLoginWebViewAssetState> toolkitLoginWebViewAssetStateObservable;
private Observable<QDeveloperProfileState> qDeveloperProfileStateObservable;
private Observable<QDevAccessBlockedState> qDevAccessBlockedStateObservable;

public Builder withAuthStateObservable(final Observable<AuthState> authStateObservable) {
this.authStateObservable = authStateObservable;
Expand Down Expand Up @@ -195,6 +209,12 @@ public Builder withQDeveloperProfileStateObservable(
return this;
}

public Builder withQDevAccessBlockedStateObservable(
final Observable<QDevAccessBlockedState> qDevAccessBlockedStateObservable) {
this.qDevAccessBlockedStateObservable = qDevAccessBlockedStateObservable;
return this;
}

public ViewRouter build() {
return new ViewRouter(this);
}
Expand Down
Loading
Loading