From a0be6c223ceac46423e0686f9755f6e39a8684e7 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Mon, 11 Aug 2025 13:19:49 +0000 Subject: [PATCH 1/7] 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 ea97a5b46a2ef..966c8902c3e73 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 495dd01f123f2..2c6f55d5920cc 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 ceec966e60841666fc761f211b7f677fff371f78 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Mon, 11 Aug 2025 15:01:29 +0000 Subject: [PATCH 2/7] 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 2c6f55d5920cc..e806bdba90b66 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/7] 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 f7ac4c04b72e83a0a8a81fbe04593956605d152d Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 13 Aug 2025 12:13:01 +0000 Subject: [PATCH 4/7] 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 73049f3e6ec19b83e68f50fac1e07a6e534e6565 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 13 Aug 2025 13:35:53 +0000 Subject: [PATCH 5/7] 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]); From 29e2625f47f34f9f50b4426876b9f10db5ce5aa3 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Mon, 18 Aug 2025 12:16:10 +0000 Subject: [PATCH 6/7] use useQuery to get external agent credentials --- site/src/api/queries/workspaces.ts | 10 ++++ site/src/modules/resources/AgentExternal.tsx | 51 +++++++++----------- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index 536925a97390f..bcfb07b75452b 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -430,3 +430,13 @@ export const updateWorkspaceACL = (workspaceId: string) => { }, }; }; + +export const workspaceAgentCredentials = ( + workspaceId: string, + agentName: string, +) => { + return { + queryKey: ["workspaces", workspaceId, "agents", agentName, "credentials"], + queryFn: () => API.getWorkspaceAgentCredentials(workspaceId, agentName), + }; +}; diff --git a/site/src/modules/resources/AgentExternal.tsx b/site/src/modules/resources/AgentExternal.tsx index a1f7c4a768669..61d1cfa02c4cd 100644 --- a/site/src/modules/resources/AgentExternal.tsx +++ b/site/src/modules/resources/AgentExternal.tsx @@ -1,10 +1,10 @@ -import { API } from "api/api"; -import { getErrorMessage } from "api/errors"; +import { workspaceAgentCredentials } from "api/queries/workspaces"; import type { Workspace, WorkspaceAgent } from "api/typesGenerated"; -import isChromatic from "chromatic/isChromatic"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; import { CodeExample } from "components/CodeExample/CodeExample"; -import { displayError } from "components/GlobalSnackbar/utils"; -import { type FC, useEffect, useState } from "react"; +import { Loader } from "components/Loader/Loader"; +import type { FC } from "react"; +import { useQuery } from "react-query"; interface AgentExternalProps { isExternalAgent: boolean; @@ -17,29 +17,24 @@ export const AgentExternal: FC = ({ agent, workspace, }) => { - const [externalAgentToken, setExternalAgentToken] = useState( - null, - ); - const [command, setCommand] = useState(null); + if (!isExternalAgent) { + return null; + } + + const { + data: credentials, + error, + isLoading, + isError, + } = useQuery(workspaceAgentCredentials(workspace.id, agent.name)); + + if (isLoading) { + return ; + } - 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); - }) - .catch((err) => { - displayError( - getErrorMessage(err, "Failed to get external agent credentials"), - ); - }); - } - }, [isExternalAgent, agent.status, workspace.id, agent.name]); + if (isError) { + return ; + } return (
@@ -48,7 +43,7 @@ export const AgentExternal: FC = ({ {workspace.name} workspace:

Date: Mon, 18 Aug 2025 13:29:52 +0000 Subject: [PATCH 7/7] Fix lint --- site/src/modules/resources/AgentExternal.stories.tsx | 4 +--- site/src/modules/resources/AgentExternal.tsx | 11 +---------- site/src/modules/resources/AgentRow.tsx | 6 +----- 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/site/src/modules/resources/AgentExternal.stories.tsx b/site/src/modules/resources/AgentExternal.stories.tsx index 2ef486e0dc33b..f83095dd1b570 100644 --- a/site/src/modules/resources/AgentExternal.stories.tsx +++ b/site/src/modules/resources/AgentExternal.stories.tsx @@ -1,14 +1,13 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; import { chromatic } from "testHelpers/chromatic"; import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities"; import { withDashboardProvider } from "testHelpers/storybook"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { AgentExternal } from "./AgentExternal"; const meta: Meta = { title: "modules/resources/AgentExternal", component: AgentExternal, args: { - isExternalAgent: true, agent: { ...MockWorkspaceAgent, status: "connecting", @@ -61,7 +60,6 @@ export const DifferentOS: Story = { export const NotExternalAgent: Story = { args: { - isExternalAgent: false, agent: { ...MockWorkspaceAgent, status: "connecting", diff --git a/site/src/modules/resources/AgentExternal.tsx b/site/src/modules/resources/AgentExternal.tsx index 61d1cfa02c4cd..aa100335fc54a 100644 --- a/site/src/modules/resources/AgentExternal.tsx +++ b/site/src/modules/resources/AgentExternal.tsx @@ -7,20 +7,11 @@ import type { FC } from "react"; import { useQuery } from "react-query"; interface AgentExternalProps { - isExternalAgent: boolean; agent: WorkspaceAgent; workspace: Workspace; } -export const AgentExternal: FC = ({ - isExternalAgent, - agent, - workspace, -}) => { - if (!isExternalAgent) { - return null; - } - +export const AgentExternal: FC = ({ agent, workspace }) => { const { data: credentials, error, diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index e806bdba90b66..6dd52bb31b5bb 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -298,11 +298,7 @@ export const AgentRow: FC = ({ {isExternalAgent && (agent.status === "timeout" || agent.status === "connecting") && workspace_external_agent && ( - + )} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy