From b0e8f655e5b6f81910976298b00bac92e82d44d0 Mon Sep 17 00:00:00 2001 From: Abhineet Jain Date: Tue, 7 Jun 2022 21:30:30 +0000 Subject: [PATCH 1/5] allow workspace update permissions to access agents --- coderd/coderd.go | 1 + coderd/coderd_test.go | 16 +++++++++++++--- coderd/httpmw/apikey.go | 4 ++-- coderd/httpmw/workspaceagentparam.go | 19 +++---------------- coderd/workspaceagents.go | 13 +++++++++++++ 5 files changed, 32 insertions(+), 21 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index ca0b9cd47fe59..6e88759fa340a 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -292,6 +292,7 @@ func New(options *Options) *API { r.Use( apiKeyMiddleware, httpmw.ExtractWorkspaceAgentParam(options.Database), + httpmw.ExtractWorkspaceParam(options.Database), ) r.Get("/", api.workspaceAgent) r.Get("/dial", api.workspaceAgentDial) diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index dbf3a5cede234..02fba90966992 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -153,10 +153,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { "GET:/api/v2/workspaceagents/me/listen": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/me/metadata": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/me/turn": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/{workspaceagent}": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/{workspaceagent}/dial": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/{workspaceagent}/iceservers": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/{workspaceagent}/pty": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/{workspaceagent}/turn": {NoAuthorize: true}, // These endpoints have more assertions. This is good, add more endpoints to assert if you can! @@ -210,6 +207,18 @@ func TestAuthorizeAllEndpoints(t *testing.T) { AssertAction: rbac.ActionRead, AssertObject: workspaceRBACObj, }, + "GET:/api/v2/workspaceagents/{workspaceagent}": { + AssertAction: rbac.ActionRead, + AssertObject: workspaceRBACObj, + }, + "GET:/api/v2/workspaceagents/{workspaceagent}/dial": { + AssertAction: rbac.ActionUpdate, + AssertObject: workspaceRBACObj, + }, + "GET:/api/v2/workspaceagents/{workspaceagent}/pty": { + AssertAction: rbac.ActionUpdate, + AssertObject: workspaceRBACObj, + }, "GET:/api/v2/workspaces/": { StatusCode: http.StatusOK, AssertAction: rbac.ActionRead, @@ -378,6 +387,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { route = strings.ReplaceAll(route, "{workspacebuild}", workspace.LatestBuild.ID.String()) route = strings.ReplaceAll(route, "{workspacename}", workspace.Name) route = strings.ReplaceAll(route, "{workspacebuildname}", workspace.LatestBuild.Name) + route = strings.ReplaceAll(route, "{workspaceagent}", workspaceResources[0].Agents[0].ID.String()) route = strings.ReplaceAll(route, "{template}", template.ID.String()) route = strings.ReplaceAll(route, "{hash}", file.Hash) route = strings.ReplaceAll(route, "{workspaceresource}", workspaceResources[0].ID.String()) diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index e2f9af9440657..acbcc9dee2619 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -37,11 +37,11 @@ type userRolesKey struct{} // AuthorizationUserRoles returns the roles used for authorization. // Comes from the ExtractAPIKey handler. func AuthorizationUserRoles(r *http.Request) database.GetAuthorizationUserRolesRow { - apiKey, ok := r.Context().Value(userRolesKey{}).(database.GetAuthorizationUserRolesRow) + userRoles, ok := r.Context().Value(userRolesKey{}).(database.GetAuthorizationUserRolesRow) if !ok { panic("developer error: user roles middleware not provided") } - return apiKey + return userRoles } // OAuth2Configs is a collection of configurations for OAuth-based authentication. diff --git a/coderd/httpmw/workspaceagentparam.go b/coderd/httpmw/workspaceagentparam.go index d9facdf605624..b958f4cd9f959 100644 --- a/coderd/httpmw/workspaceagentparam.go +++ b/coderd/httpmw/workspaceagentparam.go @@ -6,6 +6,8 @@ import ( "errors" "net/http" + "github.com/go-chi/chi/v5" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" ) @@ -74,24 +76,9 @@ func ExtractWorkspaceAgentParam(db database.Store) func(http.Handler) http.Handl }) return } - workspace, err := db.GetWorkspaceByID(r.Context(), build.WorkspaceID) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: "Internal error fetching workspace.", - Detail: err.Error(), - }) - return - } - - apiKey := APIKey(r) - if apiKey.UserID != workspace.OwnerID { - httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ - Message: "Getting non-personal agents isn't supported.", - }) - return - } ctx := context.WithValue(r.Context(), workspaceAgentParamContextKey{}, agent) + chi.RouteContext(ctx).URLParams.Add("workspace", build.WorkspaceID.String()) next.ServeHTTP(rw, r.WithContext(ctx)) }) } diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 8ba6a355e5ecc..a7de85c234ef4 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -21,6 +21,7 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/turnconn" "github.com/coder/coder/codersdk" "github.com/coder/coder/peer" @@ -31,6 +32,10 @@ import ( func (api *API) workspaceAgent(rw http.ResponseWriter, r *http.Request) { workspaceAgent := httpmw.WorkspaceAgentParam(r) + workspace := httpmw.WorkspaceParam(r) + if !api.Authorize(rw, r, rbac.ActionRead, workspace) { + return + } dbApps, err := api.Database.GetWorkspaceAppsByAgentID(r.Context(), workspaceAgent.ID) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ @@ -58,6 +63,10 @@ func (api *API) workspaceAgentDial(rw http.ResponseWriter, r *http.Request) { defer api.websocketWaitGroup.Done() workspaceAgent := httpmw.WorkspaceAgentParam(r) + workspace := httpmw.WorkspaceParam(r) + if !api.Authorize(rw, r, rbac.ActionUpdate, workspace) { + return + } apiAgent, err := convertWorkspaceAgent(workspaceAgent, nil, api.AgentConnectionUpdateFrequency) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ @@ -369,6 +378,10 @@ func (api *API) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) { defer api.websocketWaitGroup.Done() workspaceAgent := httpmw.WorkspaceAgentParam(r) + workspace := httpmw.WorkspaceParam(r) + if !api.Authorize(rw, r, rbac.ActionUpdate, workspace) { + return + } apiAgent, err := convertWorkspaceAgent(workspaceAgent, nil, api.AgentConnectionUpdateFrequency) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ From edf985b6a40e143a557c194b58454fd826b40c8e Mon Sep 17 00:00:00 2001 From: Abhineet Jain Date: Thu, 9 Jun 2022 18:19:14 +0000 Subject: [PATCH 2/5] do not show app links to users without workspace update access --- site/src/components/Resources/Resources.tsx | 47 +++++----- .../Workspace/Workspace.stories.tsx | 7 ++ site/src/components/Workspace/Workspace.tsx | 9 +- .../src/pages/WorkspacePage/WorkspacePage.tsx | 16 +++- site/src/xServices/auth/authSelectors.ts | 4 + .../xServices/workspace/workspaceXService.ts | 89 +++++++++++++++++-- 6 files changed, 137 insertions(+), 35 deletions(-) diff --git a/site/src/components/Resources/Resources.tsx b/site/src/components/Resources/Resources.tsx index 9ee4581af6e4c..4e36fc17e2fad 100644 --- a/site/src/components/Resources/Resources.tsx +++ b/site/src/components/Resources/Resources.tsx @@ -63,9 +63,10 @@ interface ResourcesProps { resources?: WorkspaceResource[] getResourcesError?: Error workspace: Workspace + canUpdateWorkspace: boolean } -export const Resources: FC = ({ resources, getResourcesError, workspace }) => { +export const Resources: FC = ({ resources, getResourcesError, workspace, canUpdateWorkspace }) => { const styles = useStyles() const theme: Theme = useTheme() @@ -89,7 +90,7 @@ export const Resources: FC = ({ resources, getResourcesError, wo - {Language.accessLabel} + {canUpdateWorkspace && {Language.accessLabel}} {Language.statusLabel} @@ -130,28 +131,30 @@ export const Resources: FC = ({ resources, getResourcesError, wo {agent.name} {agent.operating_system} - - - {agent.status === "connected" && ( - - )} - {agent.status === "connected" && - agent.apps.map((app) => ( - + + {agent.status === "connected" && ( + - ))} - - + )} + {agent.status === "connected" && + agent.apps.map((app) => ( + + ))} + + + )} {getDisplayAgentStatus(theme, agent).status} diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index 2b2cb317bde1b..0ed600e104f84 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -22,6 +22,13 @@ Started.args = { handleStop: action("stop"), resources: [Mocks.MockWorkspaceResource, Mocks.MockWorkspaceResource2], builds: [Mocks.MockWorkspaceBuild], + canUpdateWorkspace: true, +} + +export const WithoutUpdateAccess = Template.bind({}) +WithoutUpdateAccess.args = { + ...Started.args, + canUpdateWorkspace: false, } export const Starting = Template.bind({}) diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index e607017341687..2e7338c5f7c71 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -28,6 +28,7 @@ export interface WorkspaceProps { resources?: TypesGen.WorkspaceResource[] getResourcesError?: Error builds?: TypesGen.WorkspaceBuild[] + canUpdateWorkspace: boolean } /** @@ -44,6 +45,7 @@ export const Workspace: FC = ({ resources, getResourcesError, builds, + canUpdateWorkspace, }) => { const styles = useStyles() const navigate = useNavigate() @@ -80,7 +82,12 @@ export const Workspace: FC = ({ {!!resources && !!resources.length && ( - + )} diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 7584c8d193a4e..7fae721212dbd 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -1,5 +1,5 @@ -import { useMachine } from "@xstate/react" -import React, { useEffect } from "react" +import { useMachine, useSelector } from "@xstate/react" +import React, { useContext, useEffect } from "react" import { Helmet } from "react-helmet" import { useParams } from "react-router-dom" import { DeleteWorkspaceDialog } from "../../components/DeleteWorkspaceDialog/DeleteWorkspaceDialog" @@ -8,6 +8,8 @@ import { FullScreenLoader } from "../../components/Loader/FullScreenLoader" import { Workspace } from "../../components/Workspace/Workspace" import { firstOrItem } from "../../util/array" import { pageTitle } from "../../util/page" +import { selectUser } from "../../xServices/auth/authSelectors" +import { XServiceContext } from "../../xServices/StateContext" import { workspaceMachine } from "../../xServices/workspace/workspaceXService" import { workspaceScheduleBannerMachine } from "../../xServices/workspaceSchedule/workspaceScheduleBannerXService" @@ -16,8 +18,13 @@ export const WorkspacePage: React.FC = () => { const username = firstOrItem(usernameQueryParam, null) const workspaceName = firstOrItem(workspaceQueryParam, null) - const [workspaceState, workspaceSend] = useMachine(workspaceMachine) - const { workspace, resources, getWorkspaceError, getResourcesError, builds } = workspaceState.context + const xServices = useContext(XServiceContext) + const me = useSelector(xServices.authXService, selectUser) + + const [workspaceState, workspaceSend] = useMachine(workspaceMachine.withContext({ userId: me?.id })) + const { workspace, resources, getWorkspaceError, getResourcesError, builds, permissions } = workspaceState.context + + const canUpdateWorkspace = !!permissions?.updateWorkspace const [bannerState, bannerSend] = useMachine(workspaceScheduleBannerMachine) @@ -56,6 +63,7 @@ export const WorkspacePage: React.FC = () => { resources={resources} getResourcesError={getResourcesError instanceof Error ? getResourcesError : undefined} builds={builds} + canUpdateWorkspace={canUpdateWorkspace} /> { export const selectPermissions = (state: AuthState): AuthContext["permissions"] => { return state.context.permissions } + +export const selectUser = (state: AuthState): AuthContext["me"] => { + return state.context.me +} diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index c24c5b968c8de..bcbb7fe03fc3a 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -17,6 +17,8 @@ const Language = { buildError: "Workspace action failed.", } +type Permissions = Record, boolean> + export interface WorkspaceContext { workspace?: TypesGen.Workspace template?: TypesGen.Template @@ -26,14 +28,18 @@ export interface WorkspaceContext { // error creating a new WorkspaceBuild buildError?: Error | unknown // these are separate from getX errors because they don't make the page unusable - refreshWorkspaceError: Error | unknown - refreshTemplateError: Error | unknown - getResourcesError: Error | unknown + refreshWorkspaceError?: Error | unknown + refreshTemplateError?: Error | unknown + getResourcesError?: Error | unknown // Builds builds?: TypesGen.WorkspaceBuild[] getBuildsError?: Error | unknown loadMoreBuildsError?: Error | unknown - cancellationMessage: string + cancellationMessage?: string + // permissions + permissions?: Permissions + checkPermissionsError?: Error | unknown + userId?: string } export type WorkspaceEvent = @@ -48,6 +54,30 @@ export type WorkspaceEvent = | { type: "LOAD_MORE_BUILDS" } | { type: "REFRESH_TIMELINE" } +export const checks = { + readWorkspace: "readWorkspace", + updateWorkspace: "updateWorkspace", +} as const + +const permissionsToCheck = (workspace: TypesGen.Workspace) => ({ + [checks.readWorkspace]: { + object: { + resource_type: "workspace", + resource_id: workspace.id, + owner_id: workspace.owner_id, + }, + action: "read", + }, + [checks.updateWorkspace]: { + object: { + resource_type: "workspace", + resource_id: workspace.id, + owner_id: workspace.owner_id, + }, + action: "update", + }, +}) + export const workspaceMachine = createMachine( { tsTypes: {} as import("./workspaceXService.typegen").Typegen0, @@ -82,6 +112,9 @@ export const workspaceMachine = createMachine( loadMoreBuilds: { data: TypesGen.WorkspaceBuild[] } + checkPermissions: { + data: TypesGen.UserAuthorizationResponse + } }, }, id: "workspaceState", @@ -99,7 +132,7 @@ export const workspaceMachine = createMachine( src: "getWorkspace", id: "getWorkspace", onDone: { - target: "ready", + target: "gettingPermissions", actions: ["assignWorkspace"], }, onError: { @@ -109,6 +142,25 @@ export const workspaceMachine = createMachine( }, tags: "loading", }, + gettingPermissions: { + entry: "clearGetPermissionsError", + invoke: { + src: "checkPermissions", + id: "checkPermissions", + onDone: [ + { + actions: ["assignPermissions"], + target: "ready", + }, + ], + onError: [ + { + actions: "assignGetPermissionsError", + target: "error", + }, + ], + }, + }, ready: { type: "parallel", states: { @@ -312,6 +364,7 @@ export const workspaceMachine = createMachine( workspace: undefined, template: undefined, build: undefined, + permissions: undefined, }), assignWorkspace: assign({ workspace: (_, event) => event.data, @@ -323,6 +376,17 @@ export const workspaceMachine = createMachine( assignTemplate: assign({ template: (_, event) => event.data, }), + assignPermissions: assign({ + // Setting event.data as Permissions to be more stricted. So we know + // what permissions we asked for. + permissions: (_, event) => event.data as Permissions, + }), + assignGetPermissionsError: assign({ + checkPermissionsError: (_, event) => event.data, + }), + clearGetPermissionsError: assign({ + checkPermissionsError: (_) => undefined, + }), assignBuild: (_, event) => assign({ build: event.data, @@ -347,7 +411,7 @@ export const workspaceMachine = createMachine( cancellationMessage: undefined, }), displayCancellationError: (context) => { - displayError(context.cancellationMessage) + displayError(context.cancellationMessage || "Cancellation failed") }, assignRefreshWorkspaceError: (_, event) => assign({ @@ -489,14 +553,23 @@ export const workspaceMachine = createMachine( if (context.workspace) { return await API.getWorkspaceBuilds(context.workspace.id) } else { - throw Error("Cannot refresh workspace without id") + throw Error("Cannot get builds without id") } }, loadMoreBuilds: async (context) => { if (context.workspace) { return await API.getWorkspaceBuilds(context.workspace.id) } else { - throw Error("Cannot refresh workspace without id") + throw Error("Cannot load more builds without id") + } + }, + checkPermissions: async (context) => { + if (context.workspace && context.userId) { + return await API.checkUserPermissions(context.userId, { + checks: permissionsToCheck(context.workspace), + }) + } else { + throw Error("Cannot check permissions without both workspace and user id") } }, }, From ec4fcccc6e97d08b57b5771af80dcf5e324f084e Mon Sep 17 00:00:00 2001 From: Abhineet Jain Date: Thu, 9 Jun 2022 20:37:12 +0000 Subject: [PATCH 3/5] address CR comments --- site/src/components/Resources/Resources.tsx | 31 +++++++++---------- .../src/pages/WorkspacePage/WorkspacePage.tsx | 7 ++++- .../xServices/workspace/workspaceXService.ts | 10 +++--- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/site/src/components/Resources/Resources.tsx b/site/src/components/Resources/Resources.tsx index 4e36fc17e2fad..ff88ac97fb883 100644 --- a/site/src/components/Resources/Resources.tsx +++ b/site/src/components/Resources/Resources.tsx @@ -131,27 +131,24 @@ export const Resources: FC = ({ resources, getResourcesError, wo {agent.name} {agent.operating_system} - {canUpdateWorkspace && ( + {canUpdateWorkspace && agent.status === "connected" && ( - {agent.status === "connected" && ( - + {agent.apps.map((app) => ( + - )} - {agent.status === "connected" && - agent.apps.map((app) => ( - - ))} + ))} )} diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 7fae721212dbd..7fcb0725c7246 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -21,7 +21,12 @@ export const WorkspacePage: React.FC = () => { const xServices = useContext(XServiceContext) const me = useSelector(xServices.authXService, selectUser) - const [workspaceState, workspaceSend] = useMachine(workspaceMachine.withContext({ userId: me?.id })) + const [workspaceState, workspaceSend] = useMachine( + workspaceMachine.withContext({ + ...workspaceMachine.initialState.context, + userId: me?.id, + }), + ) const { workspace, resources, getWorkspaceError, getResourcesError, builds, permissions } = workspaceState.context const canUpdateWorkspace = !!permissions?.updateWorkspace diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index bcbb7fe03fc3a..1a8d94395bb4c 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -28,14 +28,14 @@ export interface WorkspaceContext { // error creating a new WorkspaceBuild buildError?: Error | unknown // these are separate from getX errors because they don't make the page unusable - refreshWorkspaceError?: Error | unknown - refreshTemplateError?: Error | unknown - getResourcesError?: Error | unknown + refreshWorkspaceError: Error | unknown + refreshTemplateError: Error | unknown + getResourcesError: Error | unknown // Builds builds?: TypesGen.WorkspaceBuild[] getBuildsError?: Error | unknown loadMoreBuildsError?: Error | unknown - cancellationMessage?: string + cancellationMessage: string // permissions permissions?: Permissions checkPermissionsError?: Error | unknown @@ -411,7 +411,7 @@ export const workspaceMachine = createMachine( cancellationMessage: undefined, }), displayCancellationError: (context) => { - displayError(context.cancellationMessage || "Cancellation failed") + displayError(context.cancellationMessage) }, assignRefreshWorkspaceError: (_, event) => assign({ From b10545efe9b6903ee92d852654f485a0eee7ddcb Mon Sep 17 00:00:00 2001 From: Abhineet Jain Date: Thu, 9 Jun 2022 22:28:12 +0000 Subject: [PATCH 4/5] initialize machine context in the hook --- site/src/pages/WorkspacePage/WorkspacePage.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 7fcb0725c7246..cbc4929646395 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -21,12 +21,11 @@ export const WorkspacePage: React.FC = () => { const xServices = useContext(XServiceContext) const me = useSelector(xServices.authXService, selectUser) - const [workspaceState, workspaceSend] = useMachine( - workspaceMachine.withContext({ - ...workspaceMachine.initialState.context, + const [workspaceState, workspaceSend] = useMachine(workspaceMachine, { + context: { userId: me?.id, - }), - ) + }, + }) const { workspace, resources, getWorkspaceError, getResourcesError, builds, permissions } = workspaceState.context const canUpdateWorkspace = !!permissions?.updateWorkspace From 4e69393174d8b5a0dae04aa1a1f324b6f5ce7a44 Mon Sep 17 00:00:00 2001 From: Abhineet Jain Date: Fri, 10 Jun 2022 13:54:25 +0000 Subject: [PATCH 5/5] revert scoped connected status check --- site/src/components/Resources/Resources.tsx | 31 +++++++++++---------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/site/src/components/Resources/Resources.tsx b/site/src/components/Resources/Resources.tsx index ff88ac97fb883..4e36fc17e2fad 100644 --- a/site/src/components/Resources/Resources.tsx +++ b/site/src/components/Resources/Resources.tsx @@ -131,24 +131,27 @@ export const Resources: FC = ({ resources, getResourcesError, wo {agent.name} {agent.operating_system} - {canUpdateWorkspace && agent.status === "connected" && ( + {canUpdateWorkspace && ( - - {agent.apps.map((app) => ( - - ))} + )} + {agent.status === "connected" && + agent.apps.map((app) => ( + + ))} )} 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