From 45735b87829b10a3d8070b0cc60e7e3319dfb1c2 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Mon, 11 Aug 2025 13:19:49 +0000 Subject: [PATCH 1/5] feat: add support for external agents in the UI and extend CodeExample --- site/src/api/api.ts | 10 +++ .../CodeExample/CodeExample.stories.tsx | 9 +++ .../components/CodeExample/CodeExample.tsx | 65 +++++++++++++++-- .../resources/AgentExternal.stories.tsx | 72 +++++++++++++++++++ site/src/modules/resources/AgentExternal.tsx | 51 +++++++++++++ site/src/modules/resources/AgentRow.tsx | 15 +++- site/src/modules/workspaces/actions.ts | 8 +++ 7 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 site/src/modules/resources/AgentExternal.stories.tsx create mode 100644 site/src/modules/resources/AgentExternal.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b9d5f06924519..e6cf5c9f3a5bc 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2022,6 +2022,16 @@ class ApiMethods { return response.data; }; + getWorkspaceAgentCredentials = async ( + workspaceID: string, + agentName: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspaces/${workspaceID}/external-agent/${agentName}/credentials`, + ); + return response.data; + }; + upsertWorkspaceAgentSharedPort = async ( workspaceID: string, req: TypesGen.UpsertWorkspaceAgentPortShareRequest, diff --git a/site/src/components/CodeExample/CodeExample.stories.tsx b/site/src/components/CodeExample/CodeExample.stories.tsx index 0213762fd31e2..61f129f448a73 100644 --- a/site/src/components/CodeExample/CodeExample.stories.tsx +++ b/site/src/components/CodeExample/CodeExample.stories.tsx @@ -31,3 +31,12 @@ export const LongCode: Story = { code: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICnKzATuWwmmt5+CKTPuRGN0R1PBemA+6/SStpLiyX+L", }, }; + +export const Redact: Story = { + args: { + secret: false, + redactPattern: /CODER_AGENT_TOKEN="([^"]+)"/g, + redactReplacement: `CODER_AGENT_TOKEN="********"`, + showRevealButton: true, + }, +}; diff --git a/site/src/components/CodeExample/CodeExample.tsx b/site/src/components/CodeExample/CodeExample.tsx index 474dcb1fac225..b69a220550958 100644 --- a/site/src/components/CodeExample/CodeExample.tsx +++ b/site/src/components/CodeExample/CodeExample.tsx @@ -1,11 +1,26 @@ import type { Interpolation, Theme } from "@emotion/react"; -import type { FC } from "react"; +import { Button } from "components/Button/Button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { EyeIcon, EyeOffIcon } from "lucide-react"; +import { type FC, useState } from "react"; import { MONOSPACE_FONT_FAMILY } from "theme/constants"; import { CopyButton } from "../CopyButton/CopyButton"; interface CodeExampleProps { code: string; + /** Defaulting to true to be on the safe side; you should have to opt out of the secure option, not remember to opt in */ secret?: boolean; + /** Redact parts of the code if the user doesn't want to obfuscate the whole code */ + redactPattern?: RegExp; + /** Replacement text for redacted content */ + redactReplacement?: string; + /** Show a button to reveal the redacted parts of the code */ + showRevealButton?: boolean; className?: string; } @@ -15,11 +30,28 @@ interface CodeExampleProps { export const CodeExample: FC = ({ code, className, - - // Defaulting to true to be on the safe side; you should have to opt out of - // the secure option, not remember to opt in secret = true, + redactPattern, + redactReplacement = "********", + showRevealButton, }) => { + const [showFullValue, setShowFullValue] = useState(false); + + const displayValue = secret + ? obfuscateText(code) + : redactPattern && !showFullValue + ? code.replace(redactPattern, redactReplacement) + : code; + + const showButtonLabel = showFullValue + ? "Hide sensitive data" + : "Show sensitive data"; + const icon = showFullValue ? ( + + ) : ( + + ); + return (
@@ -33,17 +65,36 @@ export const CodeExample: FC = ({ * 2. Even with it turned on and supported, the plaintext is still * readily available in the HTML itself */} - {obfuscateText(code)} + {displayValue} Encrypted text. Please access via the copy button. ) : ( - code + displayValue )} - +
+ {showRevealButton && redactPattern && !secret && ( + + + + + + {showButtonLabel} + + + )} + +
); }; diff --git a/site/src/modules/resources/AgentExternal.stories.tsx b/site/src/modules/resources/AgentExternal.stories.tsx new file mode 100644 index 0000000000000..ed49a10efeee6 --- /dev/null +++ b/site/src/modules/resources/AgentExternal.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { chromatic } from "testHelpers/chromatic"; +import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities"; +import { withDashboardProvider } from "testHelpers/storybook"; +import { AgentExternal } from "./AgentExternal"; + +const meta: Meta = { + title: "modules/resources/AgentExternal", + component: AgentExternal, + args: { + isExternalAgent: true, + agent: { + ...MockWorkspaceAgent, + status: "connecting", + operating_system: "linux", + architecture: "amd64", + }, + workspace: MockWorkspace, + }, + decorators: [withDashboardProvider], + parameters: { + chromatic, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Connecting: Story = { + args: { + agent: { + ...MockWorkspaceAgent, + status: "connecting", + operating_system: "linux", + architecture: "amd64", + }, + }, +}; + +export const Timeout: Story = { + args: { + agent: { + ...MockWorkspaceAgent, + status: "timeout", + operating_system: "linux", + architecture: "amd64", + }, + }, +}; + +export const DifferentOS: Story = { + args: { + agent: { + ...MockWorkspaceAgent, + status: "connecting", + operating_system: "darwin", + architecture: "arm64", + }, + }, +}; + +export const NotExternalAgent: Story = { + args: { + isExternalAgent: false, + agent: { + ...MockWorkspaceAgent, + status: "connecting", + operating_system: "linux", + architecture: "amd64", + }, + }, +}; diff --git a/site/src/modules/resources/AgentExternal.tsx b/site/src/modules/resources/AgentExternal.tsx new file mode 100644 index 0000000000000..64fea9e23ed12 --- /dev/null +++ b/site/src/modules/resources/AgentExternal.tsx @@ -0,0 +1,51 @@ +import { API } from "api/api"; +import type { Workspace, WorkspaceAgent } from "api/typesGenerated"; +import isChromatic from "chromatic/isChromatic"; +import { CodeExample } from "components/CodeExample/CodeExample"; +import { type FC, useEffect, useState } from "react"; + +interface AgentExternalProps { + isExternalAgent: boolean; + agent: WorkspaceAgent; + workspace: Workspace; +} + +export const AgentExternal: FC = ({ + isExternalAgent, + agent, + workspace, +}) => { + const [externalAgentToken, setExternalAgentToken] = useState( + null, + ); + const [command, setCommand] = useState(null); + + const origin = isChromatic() ? "https://example.com" : window.location.origin; + useEffect(() => { + if ( + isExternalAgent && + (agent.status === "timeout" || agent.status === "connecting") + ) { + API.getWorkspaceAgentCredentials(workspace.id, agent.name).then((res) => { + setExternalAgentToken(res.agent_token); + setCommand(res.command); + }); + } + }, [isExternalAgent, agent.status, workspace.id, agent.name]); + + return ( +
+

+ Please run the following command to attach an agent to the{" "} + {workspace.name} workspace: +

+ +
+ ); +}; diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 3cf757a15c2ab..e642f6494a176 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -27,6 +27,7 @@ import AutoSizer from "react-virtualized-auto-sizer"; import type { FixedSizeList as List, ListOnScrollProps } from "react-window"; import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps"; import { AgentDevcontainerCard } from "./AgentDevcontainerCard"; +import { AgentExternal } from "./AgentExternal"; import { AgentLatency } from "./AgentLatency"; import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine"; import { AgentLogs } from "./AgentLogs/AgentLogs"; @@ -62,9 +63,10 @@ export const AgentRow: FC = ({ const appSections = organizeAgentApps(agent.apps); const hasAppsToDisplay = !browser_only || appSections.some((it) => it.apps.length > 0); + const isExternalAgent = workspace.latest_build.has_external_agent; const shouldDisplayAgentApps = (agent.status === "connected" && hasAppsToDisplay) || - agent.status === "connecting"; + (agent.status === "connecting" && !isExternalAgent); const hasVSCodeApp = agent.display_apps.includes("vscode") || agent.display_apps.includes("vscode_insiders"); @@ -258,7 +260,7 @@ export const AgentRow: FC = ({ )} - {agent.status === "connecting" && ( + {agent.status === "connecting" && !isExternalAgent && (
= ({
)} + {isExternalAgent && + (agent.status === "timeout" || agent.status === "connecting") && ( + + )} + diff --git a/site/src/modules/workspaces/actions.ts b/site/src/modules/workspaces/actions.ts index 8b17d3e937c74..533cf981ed6d8 100644 --- a/site/src/modules/workspaces/actions.ts +++ b/site/src/modules/workspaces/actions.ts @@ -63,6 +63,14 @@ export const abilitiesByWorkspaceStatus = ( }; } + if (workspace.latest_build.has_external_agent) { + return { + actions: [], + canCancel: false, + canAcceptJobs: true, + }; + } + const status = workspace.latest_build.status; switch (status) { From 50bfa36510a0f93a2fb330a6111ea841b0f07c2e Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Mon, 11 Aug 2025 15:01:29 +0000 Subject: [PATCH 2/5] hide AgentExternal if feature is disabled --- site/src/modules/resources/AgentRow.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index e642f6494a176..270c32c9621bc 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -59,7 +59,7 @@ export const AgentRow: FC = ({ onUpdateAgent, initialMetadata, }) => { - const { browser_only } = useFeatureVisibility(); + const { browser_only, workspace_external_agent } = useFeatureVisibility(); const appSections = organizeAgentApps(agent.apps); const hasAppsToDisplay = !browser_only || appSections.some((it) => it.apps.length > 0); @@ -296,7 +296,8 @@ export const AgentRow: FC = ({ )} {isExternalAgent && - (agent.status === "timeout" || agent.status === "connecting") && ( + (agent.status === "timeout" || agent.status === "connecting") && + workspace_external_agent && ( Date: Mon, 11 Aug 2025 15:18:13 +0000 Subject: [PATCH 3/5] Fix AgentExternal story --- site/src/modules/resources/AgentExternal.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/modules/resources/AgentExternal.stories.tsx b/site/src/modules/resources/AgentExternal.stories.tsx index ed49a10efeee6..2ef486e0dc33b 100644 --- a/site/src/modules/resources/AgentExternal.stories.tsx +++ b/site/src/modules/resources/AgentExternal.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { chromatic } from "testHelpers/chromatic"; import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities"; import { withDashboardProvider } from "testHelpers/storybook"; From 956e5fb5955c69f928fc42dace152d87e53f7280 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 13 Aug 2025 12:13:01 +0000 Subject: [PATCH 4/5] Handle errors when fetching external agent credentials in AgentExternal component --- site/src/modules/resources/AgentExternal.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/site/src/modules/resources/AgentExternal.tsx b/site/src/modules/resources/AgentExternal.tsx index 64fea9e23ed12..1f5f89a1fa75e 100644 --- a/site/src/modules/resources/AgentExternal.tsx +++ b/site/src/modules/resources/AgentExternal.tsx @@ -1,7 +1,9 @@ import { API } from "api/api"; +import { getErrorMessage } from "api/errors"; import type { Workspace, WorkspaceAgent } from "api/typesGenerated"; import isChromatic from "chromatic/isChromatic"; import { CodeExample } from "components/CodeExample/CodeExample"; +import { displayError } from "components/GlobalSnackbar/utils"; import { type FC, useEffect, useState } from "react"; interface AgentExternalProps { @@ -29,6 +31,8 @@ export const AgentExternal: FC = ({ API.getWorkspaceAgentCredentials(workspace.id, agent.name).then((res) => { setExternalAgentToken(res.agent_token); setCommand(res.command); + }).catch((err) => { + displayError(getErrorMessage(err, "Failed to get external agent credentials")); }); } }, [isExternalAgent, agent.status, workspace.id, agent.name]); From bf67f24483131ff230175dfe38cca55d5aa48f22 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 13 Aug 2025 13:35:53 +0000 Subject: [PATCH 5/5] Fix lint --- site/src/modules/resources/AgentExternal.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/site/src/modules/resources/AgentExternal.tsx b/site/src/modules/resources/AgentExternal.tsx index 1f5f89a1fa75e..a1f7c4a768669 100644 --- a/site/src/modules/resources/AgentExternal.tsx +++ b/site/src/modules/resources/AgentExternal.tsx @@ -28,12 +28,16 @@ export const AgentExternal: FC = ({ isExternalAgent && (agent.status === "timeout" || agent.status === "connecting") ) { - API.getWorkspaceAgentCredentials(workspace.id, agent.name).then((res) => { - setExternalAgentToken(res.agent_token); - setCommand(res.command); - }).catch((err) => { - displayError(getErrorMessage(err, "Failed to get external agent credentials")); - }); + API.getWorkspaceAgentCredentials(workspace.id, agent.name) + .then((res) => { + setExternalAgentToken(res.agent_token); + setCommand(res.command); + }) + .catch((err) => { + displayError( + getErrorMessage(err, "Failed to get external agent credentials"), + ); + }); } }, [isExternalAgent, agent.status, workspace.id, agent.name]);