diff --git a/site/src/modules/apps/WorkspaceAppFrame.tsx b/site/src/modules/apps/WorkspaceAppFrame.tsx index 4ff610f88ac80..bf77bbbe1e961 100644 --- a/site/src/modules/apps/WorkspaceAppFrame.tsx +++ b/site/src/modules/apps/WorkspaceAppFrame.tsx @@ -63,7 +63,7 @@ export const WorkspaceAppFrame: FC = ({ variant="subtle" onClick={(e) => { e.preventDefault(); - if (frameRef.current?.contentWindow) { + if (link.href && frameRef.current?.contentWindow) { frameRef.current.contentWindow.location.href = link.href; } }} @@ -83,7 +83,11 @@ export const WorkspaceAppFrame: FC = ({ - + Open app in new tab diff --git a/site/src/modules/apps/useAppLink.ts b/site/src/modules/apps/useAppLink.ts index e53ee9cb84611..9aaee91bec984 100644 --- a/site/src/modules/apps/useAppLink.ts +++ b/site/src/modules/apps/useAppLink.ts @@ -1,7 +1,8 @@ import type React from "react"; -import { useQuery } from "react-query"; +import { useMutation } from "react-query"; import { toast } from "sonner"; -import { apiKey } from "#/api/queries/users"; +import { API } from "#/api/api"; +import { getErrorMessage } from "#/api/errors"; import type { Workspace, WorkspaceAgent, @@ -21,10 +22,12 @@ type UseAppLinkParams = { }; type AppLink = { - href: string; + // Token-backed external apps intentionally expose no href: their URL is only + // complete once a session token is minted on click. + href: string | undefined; onClick: (e: React.MouseEvent) => void; label: string; - hasToken: boolean; + isLoading: boolean; }; export const useAppLink = ( @@ -33,20 +36,100 @@ export const useAppLink = ( ): AppLink => { const label = app.display_name ?? app.slug; const { proxy } = useProxy(); - const { data: apiKeyResponse } = useQuery({ - ...apiKey(), - enabled: isExternalApp(app) && needsSessionToken(app), - }); - const href = getAppHref(app, { - agent, - workspace, - token: apiKeyResponse?.key, - path: proxy.preferredPathAppURL, - host: proxy.preferredWildcardHostname, + // External apps that embed the session token in their URL need a freshly + // minted key. We defer minting until the user clicks (see `onClick`) rather + // than on mount, so that merely rendering a link no longer mints (and + // audits) a session key for an app the user may never open. + const requiresSessionToken = isExternalApp(app) && needsSessionToken(app); + + const buildHref = (token: string): string => + getAppHref(app, { + agent, + workspace, + token, + path: proxy.preferredPathAppURL, + host: proxy.preferredWildcardHostname, + }); + + // Custom-protocol (non-HTTP) external apps can silently fail when the target + // application isn't installed. The browser blurs when it hands control to + // the protocol handler, which clears the timeout before the error fires. + const notifyOnOpenExternalAppFailed = () => { + const openAppExternallyFailedTimeout = 1500; + const openAppExternallyFailed = setTimeout(() => { + // Check if this is a JetBrains IDE app + // starts with "jetbrains-gateway://connect#type=coder" (from https://registry.coder.com/modules/coder/jetbrains-gateway) + const isJetBrainsGateway = app.url?.startsWith("jetbrains-gateway:"); + // starts with "jetbrains://gateway/coder" (from https://registry.coder.com/modules/coder/jetbrains) + const isJetBrainsToolbox = app.url?.startsWith("jetbrains:"); + + // Check if this is a coder:// URL + const isCoderApp = app.url?.startsWith("coder:"); + + if (isJetBrainsGateway) { + toast.error(`Failed to open "${label}".`, { + description: "JetBrains Gateway must be installed.", + }); + } else if (isJetBrainsToolbox) { + toast.error(`Failed to open "${label}".`, { + description: "JetBrains Toolbox must be installed.", + }); + } else if (isCoderApp) { + toast.error(`Failed to open "${label}".`, { + description: "Coder Desktop must be installed.", + }); + } else { + toast.error(`Failed to open "${label}".`, { + description: "The app must be installed first.", + }); + } + }, openAppExternallyFailedTimeout); + window.addEventListener( + "blur", + () => { + clearTimeout(openAppExternallyFailed); + }, + { once: true }, + ); + }; + + // The success/error handlers live on the mutation (not on the `mutate` call) + // so they still run when the triggering element unmounts before the request + // settles, e.g. a dropdown menu item that closes on select. Callbacks passed + // to `mutate` are dropped once the observer unmounts, which would otherwise + // swallow both the navigation and the failure toast. + const generateKeyMutation = useMutation({ + mutationFn: () => API.getApiKey(), + onSuccess: ({ key }) => { + notifyOnOpenExternalAppFailed(); + location.href = buildHref(key); + }, + onError: (error) => { + toast.error(getErrorMessage(error, `Failed to open "${label}".`)); + }, }); + // Token-backed apps expose no navigable href: the token is minted on click + // and the final URL is built then. Exposing a tokenless href would let + // middle-click or "Open link" launch the custom protocol with an empty + // token, so we omit it entirely. Non-token apps still render as anchors. + const href = requiresSessionToken ? undefined : buildHref(""); + const onClick = (e: React.MouseEvent) => { + // Apps that embed a session token mint it on click instead of on mount. + // These are always custom-protocol (non-HTTP) external apps, so we build + // the final URL with the freshly minted token and navigate to it via + // `location.href`, relying on the browser's protocol handler. + if (requiresSessionToken) { + e.preventDefault(); + if (generateKeyMutation.isPending) { + return; + } + generateKeyMutation.mutate(); + return; + } + if (!e.currentTarget.getAttribute("href")) { return; } @@ -57,41 +140,7 @@ export const useAppLink = ( app.external && app.url && !app.url.startsWith("http"); if (isExternalProtocolApp) { - // When browser recognizes the protocol and is able to navigate to the app, - // it will blur away, and will stop the timer. Otherwise, - // an error message will be displayed. - const openAppExternallyFailedTimeout = 1500; - const openAppExternallyFailed = setTimeout(() => { - // Check if this is a JetBrains IDE app - // starts with "jetbrains-gateway://connect#type=coder" (from https://registry.coder.com/modules/coder/jetbrains-gateway) - const isJetBrainsGateway = app.url?.startsWith("jetbrains-gateway:"); - // starts with "jetbrains://gateway/coder" (from https://registry.coder.com/modules/coder/jetbrains) - const isJetBrainsToolbox = app.url?.startsWith("jetbrains:"); - - // Check if this is a coder:// URL - const isCoderApp = app.url?.startsWith("coder:"); - - if (isJetBrainsGateway) { - toast.error(`Failed to open "${label}".`, { - description: "JetBrains Gateway must be installed.", - }); - } else if (isJetBrainsToolbox) { - toast.error(`Failed to open "${label}".`, { - description: "JetBrains Toolbox must be installed.", - }); - } else if (isCoderApp) { - toast.error(`Failed to open "${label}".`, { - description: "Coder Desktop must be installed.", - }); - } else { - toast.error(`Failed to open "${label}".`, { - description: "The app must be installed first.", - }); - } - }, openAppExternallyFailedTimeout); - window.addEventListener("blur", () => { - clearTimeout(openAppExternallyFailed); - }); + notifyOnOpenExternalAppFailed(); // Custom protocol external apps don't support open_in since they // rely on the browser's protocol handling. @@ -101,7 +150,9 @@ export const useAppLink = ( switch (app.open_in) { case "slim-window": { e.preventDefault(); - openAppInNewWindow(href); + if (href) { + openAppInNewWindow(href); + } return; } } @@ -111,6 +162,6 @@ export const useAppLink = ( href, onClick, label, - hasToken: Boolean(apiKeyResponse?.key), + isLoading: generateKeyMutation.isPending, }; }; diff --git a/site/src/modules/resources/AppLink/AppLink.stories.tsx b/site/src/modules/resources/AppLink/AppLink.stories.tsx index 37cd7100b684b..5295c1649c0aa 100644 --- a/site/src/modules/resources/AppLink/AppLink.stories.tsx +++ b/site/src/modules/resources/AppLink/AppLink.stories.tsx @@ -1,5 +1,13 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, screen, spyOn, userEvent, within } from "storybook/test"; +import { + expect, + screen, + spyOn, + userEvent, + waitFor, + within, +} from "storybook/test"; +import { API } from "#/api/api"; import { getPreferredProxy } from "#/contexts/ProxyContext"; import { MockPrimaryWorkspaceProxy, @@ -257,6 +265,91 @@ export const WithTooltip: Story = { }, }; +// Regression test for DEVEX-460: external apps that embed the session token +// must not mint an API key on render. The key is minted only when the user +// clicks the link. +export const ExternalAppDefersSessionToken: Story = { + decorators: [withToaster], + // Install the spy before the component renders. `play` runs after render and + // its effects, so a regression back to eager (on-mount) minting would fetch a + // key before a spy installed in `play` exists, and the later + // `not.toHaveBeenCalled()` assertion would still pass. + beforeEach: () => { + // Never resolve: we only assert whether/when the request fires, and + // leaving it pending avoids the subsequent protocol-handler navigation. + const getApiKey = spyOn(API, "getApiKey").mockImplementation( + () => new Promise(() => {}), + ); + return () => { + getApiKey.mockRestore(); + }; + }, + args: { + workspace: MockWorkspace, + app: { + ...MockWorkspaceApp, + external: true, + url: "jetbrains-gateway://connect?token=$SESSION_TOKEN", + }, + agent: MockWorkspaceAgent, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + // Token-backed apps expose no href and render as a button (the token is + // minted on click), so query by role "button". + const trigger = await canvas.findByRole("button"); + const user = userEvent.setup(); + + await step("no API key is minted on render", async () => { + expect(API.getApiKey).not.toHaveBeenCalled(); + }); + + await step("clicking mints the API key on demand", async () => { + await user.click(trigger); + await waitFor(() => expect(API.getApiKey).toHaveBeenCalledTimes(1)); + }); + }, +}; + +// External apps that do not embed the session token must never mint a key, +// even on click, so we don't create session keys for apps that don't need one. +export const ExternalAppWithoutSessionTokenNeverMints: Story = { + decorators: [withToaster], + // Install the spy before render so an on-mount mint would be observed. + beforeEach: () => { + const getApiKey = spyOn(API, "getApiKey").mockResolvedValue({ + key: "test-key", + }); + return () => { + getApiKey.mockRestore(); + }; + }, + args: { + workspace: MockWorkspace, + app: { + ...MockWorkspaceApp, + external: true, + url: "https://example.com", + open_in: "slim-window", + }, + agent: MockWorkspaceAgent, + }, + play: async ({ canvasElement, step }) => { + // The app opens in a slim window, so stub window.open to keep the click + // from navigating the test frame. + spyOn(window, "open").mockReturnValue(null); + const canvas = within(canvasElement); + const link = await canvas.findByRole("link"); + const user = userEvent.setup(); + + await step("no API key is minted on render or click", async () => { + expect(API.getApiKey).not.toHaveBeenCalled(); + await user.click(link); + expect(API.getApiKey).not.toHaveBeenCalled(); + }); + }, +}; + export const SlimWindowPopupBlocked: Story = { decorators: [withToaster], args: { diff --git a/site/src/modules/resources/AppLink/AppLink.tsx b/site/src/modules/resources/AppLink/AppLink.tsx index 7b660ff039bbf..225705ab529bf 100644 --- a/site/src/modules/resources/AppLink/AppLink.tsx +++ b/site/src/modules/resources/AppLink/AppLink.tsx @@ -21,8 +21,6 @@ import { useProxy } from "#/contexts/ProxyContext"; import { isAppBlockedByMissingWildcard, isAppUrlValid, - isExternalApp, - needsSessionToken, } from "#/modules/apps/apps"; import { useAppLink } from "#/modules/apps/useAppLink"; import { docs } from "#/utils/docs"; @@ -132,8 +130,11 @@ export const AppLink: FC = ({ ); } - if (isExternalApp(app) && needsSessionToken(app) && !link.hasToken) { - canClick = false; + // The session token for external apps is minted on click, so key generation + // no longer gates clickability. While a click is minting a token, show a + // spinner to reflect the in-flight request. + if (link.isLoading) { + icon = ; } if ( @@ -156,32 +157,44 @@ export const AppLink: FC = ({ shareIcon: null, }; + // Token-minting external apps expose no navigable href (see useAppLink): the + // URL is only complete after the on-click mint. Render them as a button so + // they stay interactive. A bare anchor without href is styled and treated as + // disabled by AgentButton, and middle-clicking one would otherwise launch + // the custom protocol with an empty token. + const opensViaClick = link.href === undefined; + + const content = ( + <> + {icon} + {link.label} + {ShareIcon && } + + ); + + const trigger = opensViaClick ? ( + + ) : ( + + {content} + + ); + const button = grouped ? ( - - - {icon} - {link.label} - {ShareIcon && } - - + {trigger} ) : ( - - - {icon} - {link.label} - {ShareIcon && } - - + {trigger} ); if (primaryTooltip || app.tooltip) { diff --git a/site/src/pages/AgentsPage/components/WorkspacePill.tsx b/site/src/pages/AgentsPage/components/WorkspacePill.tsx index 1a194ba3da70c..c2a443b8d842a 100644 --- a/site/src/pages/AgentsPage/components/WorkspacePill.tsx +++ b/site/src/pages/AgentsPage/components/WorkspacePill.tsx @@ -39,8 +39,6 @@ import { useIsBelowMdViewport } from "#/hooks/useIsBelowMdViewport"; import { getTerminalHref, getVSCodeHref, - isExternalApp, - needsSessionToken, openAppInNewWindow, } from "#/modules/apps/apps"; import { useAppLink } from "#/modules/apps/useAppLink"; @@ -338,13 +336,10 @@ const AppMenuItem: FC<{ }> = ({ app, workspace, agent, isRunning }) => { const link = useAppLink(app, { workspace, agent }); - const canClick = - !isExternalApp(app) || !needsSessionToken(app) || link.hasToken; - return ( - + - + {app.icon ? : } {link.label} - + ); }; @@ -201,7 +208,7 @@ const TaskAppTab: FC = ({ }); return ( - + {app.icon ? : } {link.label} {app.health === "unhealthy" && (