From 9488c1c01a3cde3ef6402cfc961206a1f0ceefdb Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 6 Aug 2026 04:56:30 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20fix(site/src):=20defer=20ext?= =?UTF-8?q?ernal=20app=20API=20key=20generation=20to=20on-click?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/modules/apps/useAppLink.ts | 140 ++++++++++++------ .../src/modules/resources/AppLink/AppLink.tsx | 9 +- .../AgentsPage/components/WorkspacePill.tsx | 9 +- site/src/pages/TaskPage/TaskApps.tsx | 11 +- 4 files changed, 107 insertions(+), 62 deletions(-) diff --git a/site/src/modules/apps/useAppLink.ts b/site/src/modules/apps/useAppLink.ts index e53ee9cb846..e58fb8961e2 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, @@ -24,7 +25,7 @@ type AppLink = { href: string; onClick: (e: React.MouseEvent) => void; label: string; - hasToken: boolean; + isLoading: boolean; }; export const useAppLink = ( @@ -33,20 +34,95 @@ 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 generateKeyMutation = useMutation({ + mutationFn: () => API.getApiKey(), }); + const buildHref = (token: string): string => + getAppHref(app, { + agent, + workspace, + token, + path: proxy.preferredPathAppURL, + host: proxy.preferredWildcardHostname, + }); + + // For apps that require a session token this href intentionally omits the + // token; the `onClick` handler mints one and navigates to the final URL. + // Callers still render it as an anchor for apps that don't need a token. + const href = buildHref(""); + + // 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 }, + ); + }; + 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(undefined, { + onSuccess: ({ key }) => { + notifyOnOpenExternalAppFailed(); + location.href = buildHref(key); + }, + onError: (error) => { + toast.error(getErrorMessage(error, `Failed to open "${label}".`)); + }, + }); + return; + } + if (!e.currentTarget.getAttribute("href")) { return; } @@ -57,41 +133,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. @@ -111,6 +153,6 @@ export const useAppLink = ( href, onClick, label, - hasToken: Boolean(apiKeyResponse?.key), + isLoading: generateKeyMutation.isPending, }; }; diff --git a/site/src/modules/resources/AppLink/AppLink.tsx b/site/src/modules/resources/AppLink/AppLink.tsx index 7b660ff039b..a53d80389a2 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 ( diff --git a/site/src/pages/AgentsPage/components/WorkspacePill.tsx b/site/src/pages/AgentsPage/components/WorkspacePill.tsx index 1a194ba3da7..c2a443b8d84 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} - + ); }; From 747709e8c78a1d212dba70e592cd47d966f464ab Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 6 Aug 2026 05:05:59 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A4=96=20test(site/src/modules/resour?= =?UTF-8?q?ces/AppLink):=20cover=20deferred=20API=20key=20minting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/AppLink/AppLink.stories.tsx | 78 ++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/site/src/modules/resources/AppLink/AppLink.stories.tsx b/site/src/modules/resources/AppLink/AppLink.stories.tsx index 37cd7100b68..16066992f32 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,74 @@ 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], + args: { + workspace: MockWorkspace, + app: { + ...MockWorkspaceApp, + external: true, + url: "jetbrains-gateway://connect?token=$SESSION_TOKEN", + }, + agent: MockWorkspaceAgent, + }, + play: async ({ canvasElement, step }) => { + // 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(() => {}), + ); + const canvas = within(canvasElement); + const link = await canvas.findByRole("link"); + const user = userEvent.setup(); + + await step("no API key is minted on render", async () => { + expect(getApiKey).not.toHaveBeenCalled(); + }); + + await step("clicking mints the API key on demand", async () => { + await user.click(link); + await waitFor(() => expect(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], + args: { + workspace: MockWorkspace, + app: { + ...MockWorkspaceApp, + external: true, + url: "https://example.com", + open_in: "slim-window", + }, + agent: MockWorkspaceAgent, + }, + play: async ({ canvasElement, step }) => { + const getApiKey = spyOn(API, "getApiKey").mockResolvedValue({ + key: "test-key", + }); + // 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(getApiKey).not.toHaveBeenCalled(); + await user.click(link); + expect(getApiKey).not.toHaveBeenCalled(); + }); + }, +}; + export const SlimWindowPopupBlocked: Story = { decorators: [withToaster], args: { From bc79053196b6ddfa7e83c7831a104575d6f48fda Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Sun, 9 Aug 2026 04:28:57 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A4=96=20fix(site):=20address=20Codex?= =?UTF-8?q?=20review=20on=20deferred=20app=20key=20minting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the getApiKey mutation success/error callbacks onto useMutation so navigation and error toasts still fire when a dropdown menu item unmounts on select (per-mutate callbacks are dropped once the observer unmounts). - Stop exposing a tokenless href for token-backed external apps; render them as a button so middle-click / open-in-new-tab cannot launch the custom protocol with an empty token. - Install the Storybook API spy before render (beforeEach) so the deferred fetch invariant is actually covered. --- site/src/modules/apps/WorkspaceAppFrame.tsx | 8 ++- site/src/modules/apps/useAppLink.ts | 49 ++++++++------- .../resources/AppLink/AppLink.stories.tsx | 45 +++++++++----- .../src/modules/resources/AppLink/AppLink.tsx | 60 +++++++++++-------- site/src/pages/TaskPage/TaskApps.tsx | 2 +- 5 files changed, 103 insertions(+), 61 deletions(-) diff --git a/site/src/modules/apps/WorkspaceAppFrame.tsx b/site/src/modules/apps/WorkspaceAppFrame.tsx index 4ff610f88ac..bf77bbbe1e9 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 e58fb8961e2..9aaee91bec9 100644 --- a/site/src/modules/apps/useAppLink.ts +++ b/site/src/modules/apps/useAppLink.ts @@ -22,7 +22,9 @@ 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; isLoading: boolean; @@ -41,10 +43,6 @@ export const useAppLink = ( // audits) a session key for an app the user may never open. const requiresSessionToken = isExternalApp(app) && needsSessionToken(app); - const generateKeyMutation = useMutation({ - mutationFn: () => API.getApiKey(), - }); - const buildHref = (token: string): string => getAppHref(app, { agent, @@ -54,11 +52,6 @@ export const useAppLink = ( host: proxy.preferredWildcardHostname, }); - // For apps that require a session token this href intentionally omits the - // token; the `onClick` handler mints one and navigates to the final URL. - // Callers still render it as an anchor for apps that don't need a token. - const href = buildHref(""); - // 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. @@ -101,6 +94,28 @@ export const useAppLink = ( ); }; + // 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 @@ -111,15 +126,7 @@ export const useAppLink = ( if (generateKeyMutation.isPending) { return; } - generateKeyMutation.mutate(undefined, { - onSuccess: ({ key }) => { - notifyOnOpenExternalAppFailed(); - location.href = buildHref(key); - }, - onError: (error) => { - toast.error(getErrorMessage(error, `Failed to open "${label}".`)); - }, - }); + generateKeyMutation.mutate(); return; } @@ -143,7 +150,9 @@ export const useAppLink = ( switch (app.open_in) { case "slim-window": { e.preventDefault(); - openAppInNewWindow(href); + if (href) { + openAppInNewWindow(href); + } return; } } diff --git a/site/src/modules/resources/AppLink/AppLink.stories.tsx b/site/src/modules/resources/AppLink/AppLink.stories.tsx index 16066992f32..5295c1649c0 100644 --- a/site/src/modules/resources/AppLink/AppLink.stories.tsx +++ b/site/src/modules/resources/AppLink/AppLink.stories.tsx @@ -270,6 +270,20 @@ export const WithTooltip: Story = { // 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: { @@ -280,22 +294,19 @@ export const ExternalAppDefersSessionToken: Story = { agent: MockWorkspaceAgent, }, play: async ({ canvasElement, step }) => { - // 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(() => {}), - ); const canvas = within(canvasElement); - const link = await canvas.findByRole("link"); + // 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(getApiKey).not.toHaveBeenCalled(); + expect(API.getApiKey).not.toHaveBeenCalled(); }); await step("clicking mints the API key on demand", async () => { - await user.click(link); - await waitFor(() => expect(getApiKey).toHaveBeenCalledTimes(1)); + await user.click(trigger); + await waitFor(() => expect(API.getApiKey).toHaveBeenCalledTimes(1)); }); }, }; @@ -304,6 +315,15 @@ export const ExternalAppDefersSessionToken: Story = { // 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: { @@ -315,9 +335,6 @@ export const ExternalAppWithoutSessionTokenNeverMints: Story = { agent: MockWorkspaceAgent, }, play: async ({ canvasElement, step }) => { - const getApiKey = spyOn(API, "getApiKey").mockResolvedValue({ - key: "test-key", - }); // 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); @@ -326,9 +343,9 @@ export const ExternalAppWithoutSessionTokenNeverMints: Story = { const user = userEvent.setup(); await step("no API key is minted on render or click", async () => { - expect(getApiKey).not.toHaveBeenCalled(); + expect(API.getApiKey).not.toHaveBeenCalled(); await user.click(link); - expect(getApiKey).not.toHaveBeenCalled(); + expect(API.getApiKey).not.toHaveBeenCalled(); }); }, }; diff --git a/site/src/modules/resources/AppLink/AppLink.tsx b/site/src/modules/resources/AppLink/AppLink.tsx index a53d80389a2..225705ab529 100644 --- a/site/src/modules/resources/AppLink/AppLink.tsx +++ b/site/src/modules/resources/AppLink/AppLink.tsx @@ -157,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/TaskPage/TaskApps.tsx b/site/src/pages/TaskPage/TaskApps.tsx index f2d794d3aac..745fccb93e6 100644 --- a/site/src/pages/TaskPage/TaskApps.tsx +++ b/site/src/pages/TaskPage/TaskApps.tsx @@ -208,7 +208,7 @@ const TaskAppTab: FC = ({ }); return ( - + {app.icon ? : } {link.label} {app.health === "unhealthy" && (