refactor: move session initialization from WebSocket to REST API (#5493)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
This commit is contained in:
Robert Brennan 2024-12-20 10:50:09 -05:00 committed by GitHub
parent 0dd919bacf
commit 73c38f1163
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 628 additions and 1442 deletions

View File

@ -71,6 +71,14 @@ or run it on tagged issues with [a github action](https://github.com/All-Hands-A
Visit [Installation](https://docs.all-hands.dev/modules/usage/installation) for more information and setup instructions.
> [!CAUTION]
> OpenHands is meant to be run by a single user on their local workstation.
> It is not appropriate for multi-tenant deployments, where multiple users share the same instance--there is no built-in isolation or scalability.
>
> If you're interested in running OpenHands in a multi-tenant environment, please
> [get in touch with us](https://docs.google.com/forms/d/e/1FAIpQLSet3VbGaz8z32gW9Wm-Grl4jpt5WgMXPgJ4EDPVmCETCBpJtQ/viewform)
> for advanced deployment options.
If you want to modify the OpenHands source code, check out [Development.md](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
Having issues? The [Troubleshooting Guide](https://docs.all-hands.dev/modules/usage/troubleshooting) can help.

View File

@ -1,338 +0,0 @@
# Kubernetes
Il existe différentes façons d'exécuter OpenHands sur Kubernetes ou OpenShift. Ce guide présente une façon possible :
1. Créer un PV "en tant qu'administrateur du cluster" pour mapper les données workspace_base et le répertoire docker au pod via le nœud worker
2. Créer un PVC pour pouvoir monter ces PV sur le pod
3. Créer un pod qui contient deux conteneurs : les conteneurs OpenHands et Sandbox
## Étapes détaillées pour l'exemple ci-dessus
> Remarque : Assurez-vous d'être connecté au cluster avec le compte approprié pour chaque étape. La création de PV nécessite un administrateur de cluster !
> Assurez-vous d'avoir les autorisations de lecture/écriture sur le hostPath utilisé ci-dessous (c'est-à-dire /tmp/workspace)
1. Créer le PV :
Le fichier yaml d'exemple ci-dessous peut être utilisé par un administrateur de cluster pour créer le PV.
- workspace-pv.yaml
```yamlfile
apiVersion: v1
kind: PersistentVolume
metadata:
name: workspace-pv
spec:
capacity:
storage: 2Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
hostPath:
path: /tmp/workspace
```
```bash
# appliquer le fichier yaml
$ oc create -f workspace-pv.yaml
persistentvolume/workspace-pv created
# vérifier :
$ oc get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
workspace-pv 2Gi RWO Retain Available 7m23s
```
- docker-pv.yaml
```yamlfile
apiVersion: v1
kind: PersistentVolume
metadata:
name: docker-pv
spec:
capacity:
storage: 2Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
hostPath:
path: /var/run/docker.sock
```
```bash
# appliquer le fichier yaml
$ oc create -f docker-pv.yaml
persistentvolume/docker-pv created
# vérifier :
oc get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
docker-pv 2Gi RWO Retain Available 6m55s
workspace-pv 2Gi RWO Retain Available 7m23s
```
2. Créer le PVC :
Exemple de fichier yaml PVC ci-dessous :
- workspace-pvc.yaml
```yamlfile
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: workspace-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
```
```bash
# créer le pvc
$ oc create -f workspace-pvc.yaml
persistentvolumeclaim/workspace-pvc created
# vérifier
$ oc get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
workspace-pvc Pending hcloud-volumes 4s
$ oc get events
LAST SEEN TYPE REASON OBJECT MESSAGE
8s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
```
- docker-pvc.yaml
```yamlfile
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: docker-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
```
```bash
# créer le pvc
$ oc create -f docker-pvc.yaml
persistentvolumeclaim/docker-pvc created
# vérifier
$ oc get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
docker-pvc Pending hcloud-volumes 4s
workspace-pvc Pending hcloud-volumes 2m53s
$ oc get events
LAST SEEN TYPE REASON OBJECT MESSAGE
10s Normal WaitForFirstConsumer persistentvolumeclaim/docker-pvc waiting for first consumer to be created before binding
10s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
```
3. Créer le fichier yaml du pod :
Exemple de fichier yaml de pod ci-dessous :
- pod.yaml
```yamlfile
apiVersion: v1
kind: Pod
metadata:
name: openhands-app-2024
labels:
app: openhands-app-2024
spec:
containers:
- name: openhands-app-2024
image: ghcr.io/all-hands-ai/openhands:main
env:
- name: SANDBOX_USER_ID
value: "1000"
- name: WORKSPACE_MOUNT_PATH
value: "/opt/workspace_base"
volumeMounts:
- name: workspace-volume
mountPath: /opt/workspace_base
- name: docker-sock
mountPath: /var/run/docker.sock
ports:
- containerPort: 3000
- name: openhands-sandbox-2024
image: ghcr.io/all-hands-ai/sandbox:main
ports:
- containerPort: 51963
command: ["/usr/sbin/sshd", "-D", "-p 51963", "-o", "PermitRootLogin=yes"]
volumes:
- name: workspace-volume
persistentVolumeClaim:
claimName: workspace-pvc
- name: docker-sock
persistentVolumeClaim:
claimName: docker-pvc
```
```bash
# créer le pod
$ oc create -f pod.yaml
W0716 11:22:07.776271 107626 warnings.go:70] would violate PodSecurity "restricted:v1.24": allowPrivilegeEscalation != false (containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.runAsNonRoot=true), seccompProfile (pod or containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
pod/openhands-app-2024 created
# L'avertissement ci-dessus peut être ignoré pour l'instant car nous ne modifierons pas les restrictions SCC.
# vérifier
$ oc get pods
NAME READY STATUS RESTARTS AGE
openhands-app-2024 0/2 Pending 0 5s
$ oc get pods
NAME READY STATUS RESTARTS AGE
openhands-app-2024 0/2 ContainerCreating 0 15s
$ oc get events
LAST SEEN TYPE REASON OBJECT MESSAGE
38s Normal WaitForFirstConsumer persistentvolumeclaim/docker-pvc waiting for first consumer to be created before binding
23s Normal ExternalProvisioning persistentvolumeclaim/docker-pvc waiting for a volume to be created, either by external provisioner "csi.hetzner.cloud" or manually created by system administrator
27s Normal Provisioning persistentvolumeclaim/docker-pvc External provisioner is provisioning volume for claim "openhands/docker-pvc"
17s Normal ProvisioningSucceeded persistentvolumeclaim/docker-pvc Successfully provisioned volume pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252
16s Normal Scheduled pod/openhands-app-2024 Successfully assigned All-Hands-AI/OpenHands-app-2024 to worker1.hub.internal.blakane.com
9s Normal SuccessfulAttachVolume pod/openhands-app-2024 AttachVolume.Attach succeeded for volume "pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252"
9s Normal SuccessfulAttachVolume pod/openhands-app-2024 AttachVolume.Attach succeeded for volume "pvc-31f15b25-faad-4665-a25f-201a530379af"
6s Normal AddedInterface pod/openhands-app-2024 Add eth0 [10.128.2.48/23] from openshift-sdn
6s Normal Pulled pod/openhands-app-2024 Container image "ghcr.io/all-hands-ai/openhands:main" already present on machine
6s Normal Created pod/openhands-app-2024 Created container openhands-app-2024
6s Normal Started pod/openhands-app-2024 Started container openhands-app-2024
6s Normal Pulled pod/openhands-app-2024 Container image "ghcr.io/all-hands-ai/sandbox:main" already present on machine
5s Normal Created pod/openhands-app-2024 Created container openhands-sandbox-2024
5s Normal Started pod/openhands-app-2024 Started container openhands-sandbox-2024
83s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
27s Normal Provisioning persistentvolumeclaim/workspace-pvc External provisioner is provisioning volume for claim "openhands/workspace-pvc"
17s Normal ProvisioningSucceeded persistentvolumeclaim/workspace-pvc Successfully provisioned volume pvc-31f15b25-faad-4665-a25f-201a530379af
$ oc get pods
NAME READY STATUS RESTARTS AGE
openhands-app-2024 2/2 Running 0 23s
$ oc get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
docker-pvc Bound pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252 10Gi RWO hcloud-volumes 10m
workspace-pvc Bound pvc-31f15b25-faad-4665-a25f-201a530379af 10Gi RWO hcloud-volumes 13m
```
4. Créer un service NodePort.
Exemple de commande de création de service ci-dessous :
```bash
# créer le service de type NodePort
$ oc create svc nodeport openhands-app-2024 --tcp=3000:3000
service/openhands-app-2024 created
# vérifier
$ oc get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
openhands-app-2024 NodePort 172.30.225.42 <none> 3000:30495/TCP 4s
$ oc describe svc openhands-app-2024
Name: openhands-app-2024
Namespace: openhands
Labels: app=openhands-app-2024
Annotations: <none>
Selector: app=openhands-app-2024
Type: NodePort
IP Family Policy: SingleStack
IP Families: IPv4
IP: 172.30.225.42
IPs: 172.30.225.42
Port: 3000-3000 3000/TCP
TargetPort: 3000/TCP
NodePort: 3000-3000 30495/TCP
Endpoints: 10.128.2.48:3000
Session Affinity: None
External Traffic Policy: Cluster
Events: <none>
```
6. Se connecter à l'interface utilisateur d'OpenHands, configurer l'Agent, puis tester :
![image](https://github.com/user-attachments/assets/12f94804-a0c7-4744-b873-e003c9caf40e)
## Déploiement d'Openhands sur GCP GKE
**Avertissement** : ce déploiement accorde à l'application OpenHands l'accès au socket docker de Kubernetes, ce qui crée un risque de sécurité. Utilisez à vos propres risques.
1- Créer une politique pour l'accès privilégié
2- Créer des informations d'identification gke (facultatif)
3- Créer le déploiement openhands
4- Commandes de vérification et d'accès à l'interface utilisateur
5- Dépanner le pod pour vérifier le conteneur interne
1. créer une politique pour l'accès privilégié
```bash
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: privileged-role
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["create", "get", "list", "watch", "delete"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["create", "get", "list", "watch", "delete"]
- apiGroups: [""]
resources: ["pods/exec"]
verbs: ["create"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: privileged-role-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: privileged-role
subjects:
- kind: ServiceAccount
name: default # Remplacez par le nom de votre compte de service
namespace: default
```
2. créer des informations d'identification gke (facultatif)
```bash
kubectl create secret generic google-cloud-key \
--from-file=key.json=/path/to/your/google-cloud-key.json
```
3. créer le déploiement openhands
## comme cela est testé pour le nœud worker unique, si vous en avez plusieurs, spécifiez l'indicateur pour le worker unique
```bash
kind: Deployment
metadata:
name: openhands-app-2024
labels:
app: openhands-app-2024
spec:
replicas: 1 # Vous pouvez augmenter ce nombre pour plusieurs réplicas
selector:
matchLabels:
app: openhands-app-2024
template:
metadata:
labels:
app: openhands-app-2024
spec:
containers:
-

View File

@ -1,343 +0,0 @@
以下是翻译后的内容:
# Kubernetes
在 Kubernetes 或 OpenShift 上运行 OpenHands 有不同的方式。本指南介绍了一种可能的方式:
1. 作为集群管理员,创建一个 PV 将 workspace_base 数据和 docker 目录映射到 worker 节点上的 pod
2. 创建一个 PVC 以便将这些 PV 挂载到 pod
3. 创建一个包含两个容器的 pod:OpenHands 和 Sandbox 容器
## 上述示例的详细步骤
> 注意:确保首先使用适当的帐户登录到集群以执行每个步骤。创建 PV 需要集群管理员权限!
> 确保你对下面使用的 hostPath(即 /tmp/workspace)有读写权限
1. 创建 PV:
集群管理员可以使用下面的示例 yaml 文件创建 PV。
- workspace-pv.yaml
```yamlfile
apiVersion: v1
kind: PersistentVolume
metadata:
name: workspace-pv
spec:
capacity:
storage: 2Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
hostPath:
path: /tmp/workspace
```
```bash
# 应用 yaml 文件
$ oc create -f workspace-pv.yaml
persistentvolume/workspace-pv created
# 查看:
$ oc get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
workspace-pv 2Gi RWO Retain Available 7m23s
```
- docker-pv.yaml
```yamlfile
apiVersion: v1
kind: PersistentVolume
metadata:
name: docker-pv
spec:
capacity:
storage: 2Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
hostPath:
path: /var/run/docker.sock
```
```bash
# 应用 yaml 文件
$ oc create -f docker-pv.yaml
persistentvolume/docker-pv created
# 查看:
oc get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
docker-pv 2Gi RWO Retain Available 6m55s
workspace-pv 2Gi RWO Retain Available 7m23s
```
2. 创建 PVC:
下面是示例 PVC yaml 文件:
- workspace-pvc.yaml
```yamlfile
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: workspace-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
```
```bash
# 创建 pvc
$ oc create -f workspace-pvc.yaml
persistentvolumeclaim/workspace-pvc created
# 查看
$ oc get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
workspace-pvc Pending hcloud-volumes 4s
$ oc get events
LAST SEEN TYPE REASON OBJECT MESSAGE
8s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
```
- docker-pvc.yaml
```yamlfile
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: docker-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
```
```bash
# 创建 pvc
$ oc create -f docker-pvc.yaml
persistentvolumeclaim/docker-pvc created
# 查看
$ oc get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
docker-pvc Pending hcloud-volumes 4s
workspace-pvc Pending hcloud-volumes 2m53s
$ oc get events
LAST SEEN TYPE REASON OBJECT MESSAGE
10s Normal WaitForFirstConsumer persistentvolumeclaim/docker-pvc waiting for first consumer to be created before binding
10s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
```
3. 创建 pod yaml 文件:
下面是示例 pod yaml 文件:
- pod.yaml
```yamlfile
apiVersion: v1
kind: Pod
metadata:
name: openhands-app-2024
labels:
app: openhands-app-2024
spec:
containers:
- name: openhands-app-2024
image: ghcr.io/all-hands-ai/openhands:main
env:
- name: SANDBOX_USER_ID
value: "1000"
- name: WORKSPACE_MOUNT_PATH
value: "/opt/workspace_base"
volumeMounts:
- name: workspace-volume
mountPath: /opt/workspace_base
- name: docker-sock
mountPath: /var/run/docker.sock
ports:
- containerPort: 3000
- name: openhands-sandbox-2024
image: ghcr.io/all-hands-ai/sandbox:main
ports:
- containerPort: 51963
command: ["/usr/sbin/sshd", "-D", "-p 51963", "-o", "PermitRootLogin=yes"]
volumes:
- name: workspace-volume
persistentVolumeClaim:
claimName: workspace-pvc
- name: docker-sock
persistentVolumeClaim:
claimName: docker-pvc
```
```bash
# 创建 pod
$ oc create -f pod.yaml
W0716 11:22:07.776271 107626 warnings.go:70] would violate PodSecurity "restricted:v1.24": allowPrivilegeEscalation != false (containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.runAsNonRoot=true), seccompProfile (pod or containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
pod/openhands-app-2024 created
# 上面的警告可以暂时忽略,因为我们不会修改 SCC 限制。
# 查看
$ oc get pods
NAME READY STATUS RESTARTS AGE
openhands-app-2024 0/2 Pending 0 5s
$ oc get pods
NAME READY STATUS RESTARTS AGE
openhands-app-2024 0/2 ContainerCreating 0 15s
$ oc get events
LAST SEEN TYPE REASON OBJECT MESSAGE
38s Normal WaitForFirstConsumer persistentvolumeclaim/docker-pvc waiting for first consumer to be created before binding
23s Normal ExternalProvisioning persistentvolumeclaim/docker-pvc waiting for a volume to be created, either by external provisioner "csi.hetzner.cloud" or manually created by system administrator
27s Normal Provisioning persistentvolumeclaim/docker-pvc External provisioner is provisioning volume for claim "openhands/docker-pvc"
17s Normal ProvisioningSucceeded persistentvolumeclaim/docker-pvc Successfully provisioned volume pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252
16s Normal Scheduled pod/openhands-app-2024 Successfully assigned All-Hands-AI/OpenHands-app-2024 to worker1.hub.internal.blakane.com
9s Normal SuccessfulAttachVolume pod/openhands-app-2024 AttachVolume.Attach succeeded for volume "pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252"
9s Normal SuccessfulAttachVolume pod/openhands-app-2024 AttachVolume.Attach succeeded for volume "pvc-31f15b25-faad-4665-a25f-201a530379af"
6s Normal AddedInterface pod/openhands-app-2024 Add eth0 [10.128.2.48/23] from openshift-sdn
6s Normal Pulled pod/openhands-app-2024 Container image "ghcr.io/all-hands-ai/openhands:main" already present on machine
6s Normal Created pod/openhands-app-2024 Created container openhands-app-2024
6s Normal Started pod/openhands-app-2024 Started container openhands-app-2024
6s Normal Pulled pod/openhands-app-2024 Container image "ghcr.io/all-hands-ai/sandbox:main" already present on machine
5s Normal Created pod/openhands-app-2024 Created container openhands-sandbox-2024
5s Normal Started pod/openhands-app-2024 Started container openhands-sandbox-2024
83s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
27s Normal Provisioning persistentvolumeclaim/workspace-pvc External provisioner is provisioning volume for claim "openhands/workspace-pvc"
17s Normal ProvisioningSucceeded persistentvolumeclaim/workspace-pvc Successfully provisioned volume pvc-31f15b25-faad-4665-a25f-201a530379af
$ oc get pods
NAME READY STATUS RESTARTS AGE
openhands-app-2024 2/2 Running 0 23s
$ oc get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
docker-pvc Bound pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252 10Gi RWO hcloud-volumes 10m
workspace-pvc Bound pvc-31f15b25-faad-4665-a25f-201a530379af 10Gi RWO hcloud-volumes 13m
```
4. 创建一个 NodePort 服务。
下面是示例服务创建命令:
```bash
# 创建 NodePort 类型的服务
$ oc create svc nodeport openhands-app-2024 --tcp=3000:3000
service/openhands-app-2024 created
# 查看
$ oc get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
openhands-app-2024 NodePort 172.30.225.42 <none> 3000:30495/TCP 4s
$ oc describe svc openhands-app-2024
Name: openhands-app-2024
Namespace: openhands
Labels: app=openhands-app-2024
Annotations: <none>
Selector: app=openhands-app-2024
Type: NodePort
IP Family Policy: SingleStack
IP Families: IPv4
IP: 172.30.225.42
IPs: 172.30.225.42
Port: 3000-3000 3000/TCP
TargetPort: 3000/TCP
NodePort: 3000-3000 30495/TCP
Endpoints: 10.128.2.48:3000
Session Affinity: None
External Traffic Policy: Cluster
Events: <none>
```
6. 连接到 OpenHands UI,配置 Agent,然后测试:
![image](https://github.com/user-attachments/assets/12f94804-a0c7-4744-b873-e003c9caf40e)
## GCP GKE OpenHands 部署
**警告**:此部署授予 OpenHands 应用程序访问 Kubernetes docker socket 的权限,这会带来安全风险。请自行决定是否使用。
1- 创建特权访问策略
2- 创建 gke 凭证(可选)
3- 创建 openhands 部署
4- 验证和 UI 访问命令
5- 排查 pod 以验证内部容器
1. 创建特权访问策略
```bash
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: privileged-role
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["create", "get", "list", "watch", "delete"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["create", "get", "list", "watch", "delete"]
- apiGroups: [""]
resources: ["pods/exec"]
verbs: ["create"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: privileged-role-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: privileged-role
subjects:
- kind: ServiceAccount
name: default # 更改为你的服务帐户名称
namespace: default
```
2. 创建 gke 凭证(可选)
```bash
kubectl create secret generic google-cloud-key \
--from-file=key.json=/path/to/your/google-cloud-key.json
```
3. 创建 openhands 部署
## 由于这是针对单个工作节点进行测试的,如果你有多个节点,请指定单个工作节点的标志
```bash
kind: Deployment
metadata:
name: openhands-app-2024
labels:
app: openhands-app-2024
spec:
replicas: 1 # 你可以增加这个数字以获得多个副本
selector:
matchLabels:
app: openhands-app-2024
template:
metadata:
labels:
app: openhands-app-2024
spec:
containers:
- name: openhands-app-2024
image: ghcr.io/all-hands-ai/openhands:main
env:
- name: SANDBOX_USER_ID
value: "1000"
- name: SANDBOX_API

View File

@ -1,429 +0,0 @@
# Kubernetes
There are different ways you might run OpenHands on Kubernetes or OpenShift. This guide goes through one possible way:
1. Create a PV "as a cluster admin" to map workspace_base data and docker directory to the pod through the worker node
2. Create a PVC to be able to mount those PVs to the pod
3. Create a pod which contains two containers; the OpenHands and Sandbox containers
## Detailed Steps for the Example Above
> Note: Make sure you are logged in to the cluster first with the proper account for each step. PV creation requires cluster administrator!
> Make sure you have read/write permissions on the hostPath used below (i.e. /tmp/workspace)
1. Create the PV:
Sample yaml file below can be used by a cluster admin to create the PV.
- workspace-pv.yaml
```yamlfile
apiVersion: v1
kind: PersistentVolume
metadata:
name: workspace-pv
spec:
capacity:
storage: 2Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
hostPath:
path: /tmp/workspace
```
```bash
# apply yaml file
$ oc create -f workspace-pv.yaml
persistentvolume/workspace-pv created
# review:
$ oc get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
workspace-pv 2Gi RWO Retain Available 7m23s
```
- docker-pv.yaml
```yamlfile
apiVersion: v1
kind: PersistentVolume
metadata:
name: docker-pv
spec:
capacity:
storage: 2Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
hostPath:
path: /var/run/docker.sock
```
```bash
# apply yaml file
$ oc create -f docker-pv.yaml
persistentvolume/docker-pv created
# review:
oc get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
docker-pv 2Gi RWO Retain Available 6m55s
workspace-pv 2Gi RWO Retain Available 7m23s
```
2. Create the PVC:
Sample PVC yaml file below:
- workspace-pvc.yaml
```yamlfile
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: workspace-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
```
```bash
# create the pvc
$ oc create -f workspace-pvc.yaml
persistentvolumeclaim/workspace-pvc created
# review
$ oc get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
workspace-pvc Pending hcloud-volumes 4s
$ oc get events
LAST SEEN TYPE REASON OBJECT MESSAGE
8s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
```
- docker-pvc.yaml
```yamlfile
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: docker-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
```
```bash
# create pvc
$ oc create -f docker-pvc.yaml
persistentvolumeclaim/docker-pvc created
# review
$ oc get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
docker-pvc Pending hcloud-volumes 4s
workspace-pvc Pending hcloud-volumes 2m53s
$ oc get events
LAST SEEN TYPE REASON OBJECT MESSAGE
10s Normal WaitForFirstConsumer persistentvolumeclaim/docker-pvc waiting for first consumer to be created before binding
10s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
```
3. Create the pod yaml file:
Sample pod yaml file below:
- pod.yaml
```yamlfile
apiVersion: v1
kind: Pod
metadata:
name: openhands-app-2024
labels:
app: openhands-app-2024
spec:
containers:
- name: openhands-app-2024
image: docker.all-hands.dev/all-hands-ai/openhands:main
env:
- name: SANDBOX_USER_ID
value: "1000"
- name: WORKSPACE_MOUNT_PATH
value: "/opt/workspace_base"
volumeMounts:
- name: workspace-volume
mountPath: /opt/workspace_base
- name: docker-sock
mountPath: /var/run/docker.sock
ports:
- containerPort: 3000
- name: openhands-sandbox-2024
image: docker.all-hands.dev/all-hands-ai/runtime:main
ports:
- containerPort: 51963
command: ["/usr/sbin/sshd", "-D", "-p 51963", "-o", "PermitRootLogin=yes"]
volumes:
- name: workspace-volume
persistentVolumeClaim:
claimName: workspace-pvc
- name: docker-sock
persistentVolumeClaim:
claimName: docker-pvc
```
```bash
# create the pod
$ oc create -f pod.yaml
W0716 11:22:07.776271 107626 warnings.go:70] would violate PodSecurity "restricted:v1.24": allowPrivilegeEscalation != false (containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.runAsNonRoot=true), seccompProfile (pod or containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
pod/openhands-app-2024 created
# Above warning can be ignored for now as we will not modify SCC restrictions.
# review
$ oc get pods
NAME READY STATUS RESTARTS AGE
openhands-app-2024 0/2 Pending 0 5s
$ oc get pods
NAME READY STATUS RESTARTS AGE
openhands-app-2024 0/2 ContainerCreating 0 15s
$ oc get events
LAST SEEN TYPE REASON OBJECT MESSAGE
38s Normal WaitForFirstConsumer persistentvolumeclaim/docker-pvc waiting for first consumer to be created before binding
23s Normal ExternalProvisioning persistentvolumeclaim/docker-pvc waiting for a volume to be created, either by external provisioner "csi.hetzner.cloud" or manually created by system administrator
27s Normal Provisioning persistentvolumeclaim/docker-pvc External provisioner is provisioning volume for claim "openhands/docker-pvc"
17s Normal ProvisioningSucceeded persistentvolumeclaim/docker-pvc Successfully provisioned volume pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252
16s Normal Scheduled pod/openhands-app-2024 Successfully assigned All-Hands-AI/OpenHands-app-2024 to worker1.hub.internal.blakane.com
9s Normal SuccessfulAttachVolume pod/openhands-app-2024 AttachVolume.Attach succeeded for volume "pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252"
9s Normal SuccessfulAttachVolume pod/openhands-app-2024 AttachVolume.Attach succeeded for volume "pvc-31f15b25-faad-4665-a25f-201a530379af"
6s Normal AddedInterface pod/openhands-app-2024 Add eth0 [10.128.2.48/23] from openshift-sdn
6s Normal Pulled pod/openhands-app-2024 Container image "docker.all-hands.dev/all-hands-ai/openhands:main" already present on machine
6s Normal Created pod/openhands-app-2024 Created container openhands-app-2024
6s Normal Started pod/openhands-app-2024 Started container openhands-app-2024
6s Normal Pulled pod/openhands-app-2024 Container image "docker.all-hands.dev/all-hands-ai/sandbox:main" already present on machine
5s Normal Created pod/openhands-app-2024 Created container openhands-sandbox-2024
5s Normal Started pod/openhands-app-2024 Started container openhands-sandbox-2024
83s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
27s Normal Provisioning persistentvolumeclaim/workspace-pvc External provisioner is provisioning volume for claim "openhands/workspace-pvc"
17s Normal ProvisioningSucceeded persistentvolumeclaim/workspace-pvc Successfully provisioned volume pvc-31f15b25-faad-4665-a25f-201a530379af
$ oc get pods
NAME READY STATUS RESTARTS AGE
openhands-app-2024 2/2 Running 0 23s
$ oc get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
docker-pvc Bound pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252 10Gi RWO hcloud-volumes 10m
workspace-pvc Bound pvc-31f15b25-faad-4665-a25f-201a530379af 10Gi RWO hcloud-volumes 13m
```
4. Create a NodePort service.
Sample service creation command below:
```bash
# create the service of type NodePort
$ oc create svc nodeport openhands-app-2024 --tcp=3000:3000
service/openhands-app-2024 created
# review
$ oc get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
openhands-app-2024 NodePort 172.30.225.42 <none> 3000:30495/TCP 4s
$ oc describe svc openhands-app-2024
Name: openhands-app-2024
Namespace: openhands
Labels: app=openhands-app-2024
Annotations: <none>
Selector: app=openhands-app-2024
Type: NodePort
IP Family Policy: SingleStack
IP Families: IPv4
IP: 172.30.225.42
IPs: 172.30.225.42
Port: 3000-3000 3000/TCP
TargetPort: 3000/TCP
NodePort: 3000-3000 30495/TCP
Endpoints: 10.128.2.48:3000
Session Affinity: None
External Traffic Policy: Cluster
Events: <none>
```
6. Connect to OpenHands UI, configure the Agent, then test:
![image](https://github.com/user-attachments/assets/12f94804-a0c7-4744-b873-e003c9caf40e)
## GCP GKE Openhands deployment
**Warning**: this deployment grants the OpenHands application access to the Kubernetes docker socket, which creates security risk. Use at your own discretion.
1- Create policy for privillege access
2- Create gke credentials(optional)
3- Create openhands deployment
4- Verification and ui access commands
5- Tshoot pod to verify the internal container
1. create policy for privillege access
```bash
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: privileged-role
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["create", "get", "list", "watch", "delete"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["create", "get", "list", "watch", "delete"]
- apiGroups: [""]
resources: ["pods/exec"]
verbs: ["create"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: privileged-role-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: privileged-role
subjects:
- kind: ServiceAccount
name: default # Change to your service account name
namespace: default
```
2. create gke credentials(optional)
```bash
kubectl create secret generic google-cloud-key \
--from-file=key.json=/path/to/your/google-cloud-key.json
```
3. create openhands deployment
## as this is tested for the single worker node if you have multiple specify the flag for the single worker
```bash
kind: Deployment
metadata:
name: openhands-app-2024
labels:
app: openhands-app-2024
spec:
replicas: 1 # You can increase this number for multiple replicas
selector:
matchLabels:
app: openhands-app-2024
template:
metadata:
labels:
app: openhands-app-2024
spec:
containers:
- name: openhands-app-2024
image: docker.all-hands.dev/all-hands-ai/openhands:main
env:
- name: SANDBOX_USER_ID
value: "1000"
- name: SANDBOX_API_HOSTNAME
value: '10.164.0.4'
- name: WORKSPACE_MOUNT_PATH
value: "/tmp/workspace_base"
- name: GOOGLE_APPLICATION_CREDENTIALS
value: "/tmp/workspace_base/google-cloud-key.json"
volumeMounts:
- name: workspace-volume
mountPath: /tmp/workspace_base
- name: docker-sock
mountPath: /var/run/docker.sock
- name: google-credentials
mountPath: "/tmp/workspace_base/google-cloud-key.json"
securityContext:
privileged: true # Add this to allow privileged access
ports:
- containerPort: 3000
- name: openhands-sandbox-2024
image: docker.all-hands.dev/all-hands-ai/runtime:main
# securityContext:
# privileged: true # Add this to allow privileged access
ports:
- containerPort: 51963
command: ["/usr/sbin/sshd", "-D", "-p 51963", "-o", "PermitRootLogin=yes"]
volumes:
#- name: workspace-volume
# persistentVolumeClaim:
# claimName: workspace-pvc
- name: workspace-volume
emptyDir: {}
- name: docker-sock
hostPath:
path: /var/run/docker.sock # Use host's Docker socket
type: Socket
- name: google-credentials
secret:
secretName: google-cloud-key
---
apiVersion: v1
kind: Service
metadata:
name: openhands-app-2024-svc
spec:
selector:
app: openhands-app-2024
ports:
- name: http
protocol: TCP
port: 80
targetPort: 3000
- name: ssh
protocol: TCP
port: 51963
targetPort: 51963
type: LoadBalancer
```
5. Tshoot pod to verify the internal container
### if you want to know more regarding the internal container runtime use below mention pod deployment use kubectl exec -it to enter into container and you can check the contaienr run time using normal docker commands like "docker ps -a"
```bash
apiVersion: apps/v1
kind: Deployment
metadata:
name: docker-in-docker
spec:
replicas: 1
selector:
matchLabels:
app: docker-in-docker
template:
metadata:
labels:
app: docker-in-docker
spec:
containers:
- name: dind
image: docker:20.10-dind
securityContext:
privileged: true
volumeMounts:
- name: docker-sock
mountPath: /var/run/docker.sock
volumes:
- name: docker-sock
hostPath:
path: /var/run/docker.sock
type: Socket
```

View File

@ -168,11 +168,6 @@ const sidebars: SidebarsConfig = {
label: 'Evaluation',
id: 'usage/how-to/evaluation-harness',
},
{
type: 'doc',
label: 'Kubernetes Deployment',
id: 'usage/how-to/openshift-example',
},
],
},
{

View File

@ -1,10 +1,38 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import * as router from "react-router";
// Mock useParams before importing components
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual as object,
useParams: () => ({ conversationId: "test-conversation-id" }),
};
});
// Mock i18next
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...actual as object,
useTranslation: () => ({
t: (key: string) => key,
i18n: {
changeLanguage: () => new Promise(() => {}),
},
}),
};
});
import { screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { renderWithProviders } from "../../test-utils";
import { BrowserPanel } from "#/components/features/browser/browser";
describe("Browser", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("renders a message if no screenshotSrc is provided", () => {
renderWithProviders(<BrowserPanel />, {
preloadedState: {

View File

@ -1,6 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as router from "react-router";
// Mock useParams before importing components
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual as object,
useParams: () => ({ conversationId: "test-conversation-id" }),
};
});
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { FeedbackForm } from "#/components/features/feedback/feedback-form";

View File

@ -1,4 +1,5 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import * as router from "react-router";
import { createRoutesStub } from "react-router";
import { screen, waitFor, within } from "@testing-library/react";
import { renderWithProviders } from "test-utils";

View File

@ -53,8 +53,12 @@ class OpenHands {
* @param path Path to list files from
* @returns List of files available in the given path. If path is not provided, it lists all the files in the workspace
*/
static async getFiles(path?: string): Promise<string[]> {
const { data } = await openHands.get<string[]>("/api/list-files", {
static async getFiles(
conversationId: string,
path?: string,
): Promise<string[]> {
const url = `/api/conversations/${conversationId}/list-files`;
const { data } = await openHands.get<string[]>(url, {
params: { path },
});
return data;
@ -65,8 +69,9 @@ class OpenHands {
* @param path Full path of the file to retrieve
* @returns Content of the file
*/
static async getFile(path: string): Promise<string> {
const { data } = await openHands.get<{ code: string }>("/api/select-file", {
static async getFile(conversationId: string, path: string): Promise<string> {
const url = `/api/conversations/${conversationId}/select-file`;
const { data } = await openHands.get<{ code: string }>(url, {
params: { file: path },
});
@ -80,12 +85,14 @@ class OpenHands {
* @returns Success message or error message
*/
static async saveFile(
conversationId: string,
path: string,
content: string,
): Promise<SaveFileSuccessResponse> {
const url = `/api/conversations/${conversationId}/save-file`;
const { data } = await openHands.post<
SaveFileSuccessResponse | ErrorResponse
>("/api/save-file", {
>(url, {
filePath: path,
content,
});
@ -99,13 +106,17 @@ class OpenHands {
* @param file File to upload
* @returns Success message or error message
*/
static async uploadFiles(files: File[]): Promise<FileUploadSuccessResponse> {
static async uploadFiles(
conversationId: string,
files: File[],
): Promise<FileUploadSuccessResponse> {
const url = `/api/conversations/${conversationId}/upload-files`;
const formData = new FormData();
files.forEach((file) => formData.append("files", file));
const { data } = await openHands.post<
FileUploadSuccessResponse | ErrorResponse
>("/api/upload-files", formData);
>(url, formData);
if ("error" in data) throw new Error(data.error);
return data;
@ -116,11 +127,12 @@ class OpenHands {
* @param data Feedback data
* @returns The stored feedback data
*/
static async submitFeedback(feedback: Feedback): Promise<FeedbackResponse> {
const { data } = await openHands.post<FeedbackResponse>(
"/api/submit-feedback",
feedback,
);
static async submitFeedback(
conversationId: string,
feedback: Feedback,
): Promise<FeedbackResponse> {
const url = `/api/conversations/${conversationId}/submit-feedback`;
const { data } = await openHands.post<FeedbackResponse>(url, feedback);
return data;
}
@ -156,8 +168,9 @@ class OpenHands {
* Get the blob of the workspace zip
* @returns Blob of the workspace zip
*/
static async getWorkspaceZip(): Promise<Blob> {
const response = await openHands.get("/api/zip-directory", {
static async getWorkspaceZip(conversationId: string): Promise<Blob> {
const url = `/api/conversations/${conversationId}/zip-directory`;
const response = await openHands.get(url, {
responseType: "blob",
});
return response.data;
@ -183,18 +196,67 @@ class OpenHands {
* Get the VSCode URL
* @returns VSCode URL
*/
static async getVSCodeUrl(): Promise<GetVSCodeUrlResponse> {
const { data } =
await openHands.get<GetVSCodeUrlResponse>("/api/vscode-url");
static async getVSCodeUrl(
conversationId: string,
): Promise<GetVSCodeUrlResponse> {
const { data } = await openHands.get<GetVSCodeUrlResponse>(
`/api/conversations/${conversationId}/vscode-url`,
);
return data;
}
static async getRuntimeId(): Promise<{ runtime_id: string }> {
static async getRuntimeId(
conversationId: string,
): Promise<{ runtime_id: string }> {
const { data } = await openHands.get<{ runtime_id: string }>(
"/api/conversation",
`/api/conversations/${conversationId}/config`,
);
return data;
}
static async searchEvents(
conversationId: string,
params: {
query?: string;
startId?: number;
limit?: number;
eventType?: string;
source?: string;
startDate?: string;
endDate?: string;
},
): Promise<{ events: Record<string, unknown>[]; has_more: boolean }> {
const { data } = await openHands.get<{
events: Record<string, unknown>[];
has_more: boolean;
}>(`/api/conversations/${conversationId}/events/search`, {
params: {
query: params.query,
start_id: params.startId,
limit: params.limit,
event_type: params.eventType,
source: params.source,
start_date: params.startDate,
end_date: params.endDate,
},
});
return data;
}
static async newConversation(params: {
githubToken?: string;
args?: Record<string, unknown>;
selectedRepository?: string;
}): Promise<{ conversation_id: string }> {
const { data } = await openHands.post<{
conversation_id: string;
}>("/api/conversations", {
github_token: params.githubToken,
args: params.args,
selected_repository: params.selectedRepository,
});
return data;
}
}
export default OpenHands;

View File

@ -45,7 +45,7 @@ export function Sidebar() {
};
const handleClickLogo = () => {
if (location.pathname.startsWith("/app"))
if (location.pathname.startsWith("/conversations/"))
setStartNewProjectModalIsOpen(true);
};

View File

@ -87,7 +87,7 @@ export function SettingsForm({
const [showWarningModal, setShowWarningModal] = React.useState(false);
const resetOngoingSession = () => {
if (location.pathname.startsWith("/app")) {
if (location.pathname.startsWith("/conversations/")) {
endSession();
onClose();
}
@ -129,7 +129,7 @@ export function SettingsForm({
if (!apiKey) {
setShowWarningModal(true);
} else if (location.pathname.startsWith("/app")) {
} else if (location.pathname.startsWith("/conversations/")) {
setConfirmEndSessionModalOpen(true);
} else {
handleFormSubmission(formData);

View File

@ -1,6 +1,7 @@
import React from "react";
import { useNavigate, useNavigation } from "react-router";
import { useDispatch, useSelector } from "react-redux";
import { useMutation } from "@tanstack/react-query";
import posthog from "posthog-js";
import { RootState } from "#/store";
import {
@ -8,6 +9,10 @@ import {
removeFile,
setInitialQuery,
} from "#/state/initial-query-slice";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { useUserPrefs } from "#/context/user-prefs-context";
import { SuggestionBubble } from "#/components/features/suggestions/suggestion-bubble";
import { SUGGESTIONS } from "#/utils/suggestions";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
@ -22,6 +27,8 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
const dispatch = useDispatch();
const navigation = useNavigation();
const navigate = useNavigate();
const { gitHubToken } = useAuth();
const { settings } = useUserPrefs();
const { selectedRepository, files } = useSelector(
(state: RootState) => state.initalQuery,
@ -32,6 +39,25 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
getRandomKey(SUGGESTIONS["non-repo"]),
);
const [inputIsFocused, setInputIsFocused] = React.useState(false);
const newConversationMutation = useMutation({
mutationFn: (variables: { q: string }) => {
dispatch(setInitialQuery(variables.q));
return OpenHands.newConversation({
githubToken: gitHubToken || undefined,
selectedRepository: selectedRepository || undefined,
args: settings || undefined,
});
},
onSuccess: ({ conversation_id: conversationId }, { q }) => {
posthog.capture("initial_query_submitted", {
entry_point: "task_form",
query_character_length: q.length,
has_repository: !!selectedRepository,
has_files: files.length > 0,
});
navigate(`/conversations/${conversationId}`);
},
});
const onRefreshSuggestion = () => {
const suggestions = SUGGESTIONS["non-repo"];
@ -62,16 +88,9 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
const formData = new FormData(event.currentTarget);
const q = formData.get("q")?.toString();
if (q) dispatch(setInitialQuery(q));
if (!q) return;
posthog.capture("initial_query_submitted", {
entry_point: "task_form",
query_character_length: q?.length,
has_repository: !!selectedRepository,
has_files: files.length > 0,
});
navigate("/app");
newConversationMutation.mutate({ q });
};
return (
@ -114,7 +133,10 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
showButton={!!text}
className="text-[17px] leading-5 py-[17px]"
buttonClassName="pb-[17px]"
disabled={navigation.state === "submitting"}
disabled={
navigation.state === "submitting" ||
newConversationMutation.isPending
}
/>
</div>
</form>

View File

@ -0,0 +1,42 @@
import React, { useMemo } from "react";
import { useParams } from "react-router";
interface ConversationContextType {
conversationId: string;
}
const ConversationContext = React.createContext<ConversationContextType | null>(
null,
);
export function ConversationProvider({
children,
}: {
children: React.ReactNode;
}) {
const { conversationId } = useParams<{ conversationId: string }>();
if (!conversationId) {
throw new Error(
"ConversationProvider must be used within a route that has a conversationId parameter",
);
}
const value = useMemo(() => ({ conversationId }), [conversationId]);
return (
<ConversationContext.Provider value={value}>
{children}
</ConversationContext.Provider>
);
}
export function useConversation() {
const context = React.useContext(ConversationContext);
if (!context) {
throw new Error(
"useConversation must be used within a ConversationProvider",
);
}
return context;
}

View File

@ -1,8 +1,7 @@
import posthog from "posthog-js";
import React from "react";
import { io, Socket } from "socket.io-client";
import { Settings } from "#/services/settings";
import ActionType from "#/types/action-type";
import EventLogger from "#/utils/event-logger";
import { handleAssistantMessage } from "#/services/actions";
import { useRate } from "#/hooks/use-rate";
@ -36,22 +35,19 @@ const WsClientContext = React.createContext<UseWsClient>({
interface WsClientProviderProps {
enabled: boolean;
token: string | null;
conversationId: string;
ghToken: string | null;
selectedRepository: string | null;
settings: Settings | null;
}
export function WsClientProvider({
enabled,
token,
ghToken,
selectedRepository,
settings,
conversationId,
children,
}: React.PropsWithChildren<WsClientProviderProps>) {
const sioRef = React.useRef<Socket | null>(null);
const tokenRef = React.useRef<string | null>(token);
const ghTokenRef = React.useRef<string | null>(ghToken);
const selectedRepositoryRef = React.useRef<string | null>(selectedRepository);
const disconnectRef = React.useRef<ReturnType<typeof setTimeout> | null>(
@ -73,25 +69,6 @@ export function WsClientProvider({
function handleConnect() {
setStatus(WsClientProviderStatus.OPENING);
const initEvent: Record<string, unknown> = {
action: ActionType.INIT,
args: settings,
};
if (token) {
initEvent.token = token;
}
if (ghToken) {
initEvent.github_token = ghToken;
}
if (selectedRepository) {
initEvent.selected_repository = selectedRepository;
}
const lastEvent = lastEventRef.current;
if (lastEvent) {
initEvent.latest_event_id = lastEvent.id;
}
send(initEvent);
}
function handleMessage(event: Record<string, unknown>) {
@ -116,9 +93,7 @@ export function WsClientProvider({
return;
}
if (!event.token) {
handleAssistantMessage(event);
}
handleAssistantMessage(event);
}
function handleDisconnect() {
@ -130,11 +105,13 @@ export function WsClientProvider({
setStatus(WsClientProviderStatus.ERROR);
}
// Connect websocket
React.useEffect(() => {
if (!conversationId) {
throw new Error("No conversation ID provided");
}
let sio = sioRef.current;
// If disabled disconnect any existing websockets...
if (!enabled) {
if (sio) {
sio.disconnect();
@ -142,21 +119,22 @@ export function WsClientProvider({
return () => {};
}
// If there is no websocket or the tokens have changed or the current websocket is disconnected,
// create a new one
if (
!sio ||
(tokenRef.current && token && token !== tokenRef.current) ||
ghToken !== ghTokenRef.current
) {
sio?.disconnect();
const lastEvent = lastEventRef.current;
const query = {
latest_event_id: lastEvent?.id ?? -1,
conversation_id: conversationId,
};
const baseUrl =
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
sio = io(baseUrl, {
transports: ["websocket"],
});
}
const baseUrl =
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
sio = io(baseUrl, {
transports: ["websocket"],
auth: {
github_token: ghToken || undefined,
},
query,
});
sio.on("connect", handleConnect);
sio.on("oh_event", handleMessage);
sio.on("connect_error", handleError);
@ -164,7 +142,6 @@ export function WsClientProvider({
sio.on("disconnect", handleDisconnect);
sioRef.current = sio;
tokenRef.current = token;
ghTokenRef.current = ghToken;
selectedRepositoryRef.current = selectedRepository;
@ -175,7 +152,7 @@ export function WsClientProvider({
sio.off("connect_failed", handleError);
sio.off("disconnect", handleDisconnect);
};
}, [enabled, token, ghToken, selectedRepository]);
}, [enabled, ghToken, selectedRepository, conversationId]);
// Strict mode mounts and unmounts each component twice, so we have to wait in the destructor
// before actually disconnecting the socket and cancel the operation if the component gets remounted.

View File

@ -1,17 +1,20 @@
import { useMutation } from "@tanstack/react-query";
import toast from "react-hot-toast";
import OpenHands from "#/api/open-hands";
import { useConversation } from "#/context/conversation-context";
type SaveFileArgs = {
path: string;
content: string;
};
export const useSaveFile = () =>
useMutation({
export const useSaveFile = () => {
const { conversationId } = useConversation();
return useMutation({
mutationFn: ({ path, content }: SaveFileArgs) =>
OpenHands.saveFile(path, content),
OpenHands.saveFile(conversationId, path, content),
onError: (error) => {
toast.error(error.message);
},
});
};

View File

@ -2,16 +2,19 @@ import { useMutation } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { Feedback } from "#/api/open-hands.types";
import OpenHands from "#/api/open-hands";
import { useConversation } from "#/context/conversation-context";
type SubmitFeedbackArgs = {
feedback: Feedback;
};
export const useSubmitFeedback = () =>
useMutation({
export const useSubmitFeedback = () => {
const { conversationId } = useConversation();
return useMutation({
mutationFn: ({ feedback }: SubmitFeedbackArgs) =>
OpenHands.submitFeedback(feedback),
OpenHands.submitFeedback(conversationId, feedback),
onError: (error) => {
toast.error(error.message);
},
});
};

View File

@ -1,11 +1,15 @@
import { useMutation } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useConversation } from "#/context/conversation-context";
type UploadFilesArgs = {
files: File[];
};
export const useUploadFiles = () =>
useMutation({
mutationFn: ({ files }: UploadFilesArgs) => OpenHands.uploadFiles(files),
export const useUploadFiles = () => {
const { conversationId } = useConversation();
return useMutation({
mutationFn: ({ files }: UploadFilesArgs) =>
OpenHands.uploadFiles(conversationId, files),
});
};

View File

@ -4,15 +4,20 @@ import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import { useConversation } from "#/context/conversation-context";
import OpenHands from "#/api/open-hands";
export const useConversationConfig = () => {
const { status } = useWsClient();
const { conversationId } = useConversation();
const query = useQuery({
queryKey: ["conversation_config"],
queryFn: OpenHands.getRuntimeId,
enabled: status === WsClientProviderStatus.ACTIVE,
queryKey: ["conversation_config", conversationId],
queryFn: () => {
if (!conversationId) throw new Error("No conversation ID");
return OpenHands.getRuntimeId(conversationId);
},
enabled: status === WsClientProviderStatus.ACTIVE && !!conversationId,
});
React.useEffect(() => {

View File

@ -1,13 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useConversation } from "#/context/conversation-context";
interface UseListFileConfig {
path: string;
}
export const useListFile = (config: UseListFileConfig) =>
useQuery({
queryKey: ["file", config.path],
queryFn: () => OpenHands.getFile(config.path),
export const useListFile = (config: UseListFileConfig) => {
const { conversationId } = useConversation();
return useQuery({
queryKey: ["file", conversationId, config.path],
queryFn: () => OpenHands.getFile(conversationId, config.path),
enabled: false, // don't fetch by default, trigger manually via `refetch`
});
};

View File

@ -4,6 +4,7 @@ import {
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import OpenHands from "#/api/open-hands";
import { useConversation } from "#/context/conversation-context";
import { useAuth } from "#/context/auth-context";
interface UseListFilesConfig {
@ -13,12 +14,13 @@ interface UseListFilesConfig {
export const useListFiles = (config?: UseListFilesConfig) => {
const { token } = useAuth();
const { conversationId } = useConversation();
const { status } = useWsClient();
const isActive = status === WsClientProviderStatus.ACTIVE;
return useQuery({
queryKey: ["files", token, config?.path],
queryFn: () => OpenHands.getFiles(config?.path),
queryKey: ["files", token, conversationId, config?.path],
queryFn: () => OpenHands.getFiles(conversationId, config?.path),
enabled: !!(isActive && config?.enabled && token),
});
};

View File

@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { useConversation } from "#/context/conversation-context";
import OpenHands from "#/api/open-hands";
export const useSearchEvents = (params: {
query?: string;
startId?: number;
limit?: number;
eventType?: string;
source?: string;
startDate?: string;
endDate?: string;
}) => {
const { conversationId } = useConversation();
return useQuery({
queryKey: ["search_events", conversationId, params],
queryFn: () => {
if (!conversationId) throw new Error("No conversation ID");
return OpenHands.searchEvents(conversationId, params);
},
enabled: !!conversationId,
});
};

View File

@ -1,11 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useConversation } from "#/context/conversation-context";
export const useVSCodeUrl = (config: { enabled: boolean }) => {
const { conversationId } = useConversation();
const data = useQuery({
queryKey: ["vscode_url"],
queryFn: OpenHands.getVSCodeUrl,
enabled: config.enabled,
queryKey: ["vscode_url", conversationId],
queryFn: () => {
if (!conversationId) throw new Error("No conversation ID");
return OpenHands.getVSCodeUrl(conversationId);
},
enabled: !!conversationId && config.enabled,
refetchOnMount: false,
});

View File

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { downloadFiles } from "#/utils/download-files";
import { DownloadProgressState } from "#/components/shared/download-progress";
import { useConversation } from "#/context/conversation-context";
export const INITIAL_PROGRESS: DownloadProgressState = {
filesTotal: 0,
@ -20,6 +21,7 @@ export function useDownloadProgress(
useState<DownloadProgressState>(INITIAL_PROGRESS);
const progressRef = useRef<DownloadProgressState>(INITIAL_PROGRESS);
const abortController = useRef<AbortController>();
const { conversationId } = useConversation();
// Create AbortController on mount
useEffect(() => {
@ -45,7 +47,7 @@ export function useDownloadProgress(
// Start download
const download = async () => {
try {
await downloadFiles(initialPath, {
await downloadFiles(conversationId, initialPath, {
onProgress: (p) => {
// Update both the ref and state
progressRef.current = { ...p };

View File

@ -8,7 +8,7 @@ import {
export default [
layout("routes/_oh/route.tsx", [
index("routes/_oh._index/route.tsx"),
route("app", "routes/_oh.app/route.tsx", [
route("conversations/:conversationId", "routes/_oh.app/route.tsx", [
index("routes/_oh.app._index/route.tsx"),
route("browser", "routes/_oh.app.browser.tsx"),
route("jupyter", "routes/_oh.app.jupyter.tsx"),

View File

@ -1,4 +1,3 @@
import { useLocation, useNavigate } from "react-router";
import React from "react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
@ -17,12 +16,8 @@ import { HeroHeading } from "#/components/shared/hero-heading";
import { TaskForm } from "#/components/shared/task-form";
function Home() {
const { token, gitHubToken } = useAuth();
const { gitHubToken } = useAuth();
const dispatch = useDispatch();
const location = useLocation();
const navigate = useNavigate();
const formRef = React.useRef<HTMLFormElement>(null);
const { data: config } = useConfig();
@ -36,10 +31,6 @@ function Home() {
gitHubClientId: config?.GITHUB_CLIENT_ID || null,
});
React.useEffect(() => {
if (token) navigate("/app");
}, [location.pathname]);
return (
<div
data-testid="root-index"

View File

@ -2,6 +2,10 @@ import { useDisclosure } from "@nextui-org/react";
import React from "react";
import { Outlet } from "react-router";
import { useDispatch, useSelector } from "react-redux";
import {
ConversationProvider,
useConversation,
} from "#/context/conversation-context";
import { Controls } from "#/components/features/controls/controls";
import { RootState } from "#/store";
import { clearMessages } from "#/state/chat-slice";
@ -24,9 +28,10 @@ import Security from "#/components/shared/modals/security/security";
import { CountBadge } from "#/components/layout/count-badge";
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
function App() {
const { token, gitHubToken } = useAuth();
function AppContent() {
const { gitHubToken } = useAuth();
const { settings } = useUserPrefs();
const { conversationId } = useConversation();
const dispatch = useDispatch();
useConversationConfig();
@ -42,8 +47,8 @@ function App() {
});
const secrets = React.useMemo(
() => [gitHubToken, token].filter((secret) => secret !== null),
[gitHubToken, token],
() => [gitHubToken].filter((secret) => secret !== null),
[gitHubToken],
);
const Terminal = React.useMemo(
@ -66,10 +71,9 @@ function App() {
return (
<WsClientProvider
enabled
token={token}
ghToken={gitHubToken}
selectedRepository={selectedRepository}
settings={settings}
conversationId={conversationId}
>
<EventHandler>
<div className="flex flex-col h-full gap-3">
@ -131,4 +135,12 @@ function App() {
);
}
function App() {
return (
<ConversationProvider>
<AppContent />
</ConversationProvider>
);
}
export default App;

View File

@ -38,11 +38,12 @@ async function createSubdirectories(
* Recursively gets all files in a directory
*/
async function getAllFiles(
conversationID: string,
path: string,
progress: DownloadProgress,
options?: DownloadOptions,
): Promise<string[]> {
const entries = await OpenHands.getFiles(path);
const entries = await OpenHands.getFiles(conversationID, path);
const processEntry = async (entry: string): Promise<string[]> => {
if (options?.signal?.aborted) {
@ -51,7 +52,7 @@ async function getAllFiles(
const fullPath = path + entry;
if (entry.endsWith("/")) {
const subEntries = await OpenHands.getFiles(fullPath);
const subEntries = await OpenHands.getFiles(conversationID, fullPath);
const subFilesPromises = subEntries.map((subEntry) =>
processEntry(subEntry),
);
@ -83,6 +84,7 @@ async function getAllFiles(
* Process a batch of files
*/
async function processBatch(
conversationID: string,
batch: string[],
directoryHandle: FileSystemDirectoryHandle,
progress: DownloadProgress,
@ -110,7 +112,7 @@ async function processBatch(
};
options?.onProgress?.(newProgress);
const content = await OpenHands.getFile(path);
const content = await OpenHands.getFile(conversationID, path);
// Save to the selected directory preserving structure
const pathParts = path.split("/").filter(Boolean);
@ -165,6 +167,7 @@ async function processBatch(
* @param options Download options including progress callback and abort signal
*/
export async function downloadFiles(
conversationID: string,
initialPath?: string,
options?: DownloadOptions,
): Promise<void> {
@ -203,7 +206,12 @@ export async function downloadFiles(
}
// Then recursively get all files
const files = await getAllFiles(initialPath || "", progress, options);
const files = await getAllFiles(
conversationID,
initialPath || "",
progress,
options,
);
// Set isDiscoveringFiles to false now that we have the full list and preserve filesTotal
const finalTotal = progress.filesTotal;
@ -270,6 +278,7 @@ export async function downloadFiles(
(promise, batch) =>
promise.then(async () => {
const { newCompleted, newBytes } = await processBatch(
conversationID,
batch,
directoryHandle,
progress,

View File

@ -3,8 +3,8 @@ import OpenHands from "#/api/open-hands";
/**
* Downloads the current workspace as a .zip file.
*/
export const downloadWorkspace = async () => {
const blob = await OpenHands.getWorkspaceZip();
export const downloadWorkspace = async (conversationId: string) => {
const blob = await OpenHands.getWorkspaceZip(conversationId);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");

View File

@ -10,8 +10,20 @@ import { I18nextProvider } from "react-i18next";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { AppStore, RootState, rootReducer } from "./src/store";
import { vi } from "vitest";
import { AuthProvider } from "#/context/auth-context";
import { UserPrefsProvider } from "#/context/user-prefs-context";
import { ConversationProvider } from "#/context/conversation-context";
// Mock useParams before importing components
vi.mock("react-router", async () => {
const actual =
await vi.importActual<typeof import("react-router")>("react-router");
return {
...actual,
useParams: () => ({ conversationId: "test-conversation-id" }),
};
});
// Initialize i18n for tests
i18n
@ -60,11 +72,13 @@ export function renderWithProviders(
<Provider store={store}>
<UserPrefsProvider>
<AuthProvider>
<QueryClientProvider client={new QueryClient()}>
<I18nextProvider i18n={i18n}>
{children}
</I18nextProvider>
</QueryClientProvider>
<ConversationProvider>
<QueryClientProvider client={new QueryClient()}>
<I18nextProvider i18n={i18n}>
{children}
</I18nextProvider>
</QueryClientProvider>
</ConversationProvider>
</AuthProvider>
</UserPrefsProvider>
</Provider>

View File

@ -10,6 +10,11 @@ from openhands.core.utils import json
from openhands.events.event import Event, EventSource
from openhands.events.serialization.event import event_from_dict, event_to_dict
from openhands.storage import FileStore
from openhands.storage.locations import (
get_conversation_dir,
get_conversation_event_file,
get_conversation_events_dir,
)
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.shutdown_listener import should_continue
@ -26,7 +31,7 @@ class EventStreamSubscriber(str, Enum):
async def session_exists(sid: str, file_store: FileStore) -> bool:
try:
await call_sync_from_async(file_store.list, f'sessions/{sid}')
await call_sync_from_async(file_store.list, get_conversation_dir(sid))
return True
except FileNotFoundError:
return False
@ -59,7 +64,7 @@ class EventStream:
def __post_init__(self) -> None:
try:
events = self.file_store.list(f'sessions/{self.sid}/events')
events = self.file_store.list(get_conversation_events_dir(self.sid))
except FileNotFoundError:
logger.debug(f'No events found for session {self.sid}')
self._cur_id = 0
@ -72,7 +77,7 @@ class EventStream:
self._cur_id = id + 1
def _get_filename_for_id(self, id: int) -> str:
return f'sessions/{self.sid}/events/{id}.json'
return get_conversation_event_file(self.sid, id)
@staticmethod
def _get_id_from_filename(filename: str) -> int:
@ -299,9 +304,3 @@ class EventStream:
break
return matching_events
def clear(self):
self.file_store.delete(f'sessions/{self.sid}')
self._cur_id = 0
# self._subscribers = {}
self.__post_init__()

View File

@ -10,16 +10,16 @@ from fastapi import (
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
from openhands.server.middleware import (
AttachSessionMiddleware,
AttachConversationMiddleware,
InMemoryRateLimiter,
LocalhostCORSMiddleware,
NoCacheMiddleware,
RateLimitMiddleware,
)
from openhands.server.routes.conversation import app as conversation_api_router
from openhands.server.routes.feedback import app as feedback_api_router
from openhands.server.routes.files import app as files_api_router
from openhands.server.routes.github import app as github_api_router
from openhands.server.routes.new_conversation import app as new_conversation_api_router
from openhands.server.routes.public import app as public_api_router
from openhands.server.routes.security import app as security_api_router
from openhands.server.routes.settings import app as settings_router
@ -54,23 +54,13 @@ async def health():
app.include_router(public_api_router)
app.include_router(files_api_router)
app.include_router(conversation_api_router)
app.include_router(security_api_router)
app.include_router(feedback_api_router)
app.include_router(new_conversation_api_router)
app.include_router(settings_router)
app.include_router(github_api_router)
AttachSessionMiddlewareImpl = get_impl(
AttachSessionMiddleware, openhands_config.attach_session_middleware_path
)
app.middleware('http')(AttachSessionMiddlewareImpl(app, target_router=files_api_router))
app.middleware('http')(
AttachSessionMiddlewareImpl(app, target_router=conversation_api_router)
)
app.middleware('http')(
AttachSessionMiddlewareImpl(app, target_router=security_api_router)
)
app.middleware('http')(
AttachSessionMiddlewareImpl(app, target_router=feedback_api_router)
AttachConversationMiddlewareImpl = get_impl(
AttachConversationMiddleware, openhands_config.attach_conversation_middleware_path
)
app.middleware('http')(AttachConversationMiddlewareImpl(app))

View File

@ -12,8 +12,8 @@ class OpenhandsConfig(OpenhandsConfigInterface):
app_mode = AppMode.OSS
posthog_client_key = 'phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA'
github_client_id = os.environ.get('GITHUB_APP_CLIENT_ID', '')
attach_session_middleware_path = (
'openhands.server.middleware.AttachSessionMiddleware'
attach_conversation_middleware_path = (
'openhands.server.middleware.AttachConversationMiddleware'
)
settings_store_class: str = (
'openhands.storage.file_settings_store.FileSettingsStore'
@ -39,22 +39,12 @@ class OpenhandsConfig(OpenhandsConfigInterface):
return config
async def github_auth(self, data: dict):
"""
Skip Github Auth for AppMode OSS
"""
pass
def load_openhands_config():
config_cls = os.environ.get('OPENHANDS_CONFIG_CLS', None)
logger.info(f'Using config class {config_cls}')
if config_cls:
openhands_config_cls = get_impl(OpenhandsConfig, config_cls)
else:
openhands_config_cls = OpenhandsConfig
openhands_config_cls = get_impl(OpenhandsConfig, config_cls)
openhands_config = openhands_config_cls()
openhands_config.verify_config()

View File

@ -1,5 +1,9 @@
from urllib.parse import parse_qs
from github import Github
from socketio.exceptions import ConnectionRefusedError
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.action import ActionType
from openhands.events.action import (
NullAction,
)
@ -9,73 +13,50 @@ from openhands.events.observation import (
from openhands.events.observation.agent import AgentStateChangedObservation
from openhands.events.serialization import event_to_dict
from openhands.events.stream import AsyncEventStreamWrapper
from openhands.server.auth import get_sid_from_token, sign_token
from openhands.server.routes.settings import SettingsStoreImpl
from openhands.server.session.session_init_data import SessionInitData
from openhands.server.session.manager import ConversationDoesNotExistError
from openhands.server.shared import config, session_manager, sio
from openhands.storage.conversation.conversation_store import (
ConversationStore,
)
from openhands.utils.async_utils import call_sync_from_async
@sio.event
async def connect(connection_id: str, environ):
async def connect(connection_id: str, environ, auth):
logger.info(f'sio:connect: {connection_id}')
query_params = parse_qs(environ.get('QUERY_STRING', ''))
latest_event_id = int(query_params.get('latest_event_id', [-1])[0])
conversation_id = query_params.get('conversation_id', [None])[0]
if not conversation_id:
logger.error('No conversation_id in query params')
raise ConnectionRefusedError('No conversation_id in query params')
user_id = ''
if auth and 'github_token' in auth:
g = Github(auth['github_token'])
gh_user = await call_sync_from_async(g.get_user)
user_id = gh_user.id
@sio.event
async def oh_action(connection_id: str, data: dict):
# If it's an init, we do it here.
action = data.get('action', '')
if action == ActionType.INIT:
await init_connection(
connection_id=connection_id,
token=data.get('token', None),
github_token=data.get('github_token', None),
session_init_args={
k.lower(): v for k, v in (data.get('args') or {}).items()
},
latest_event_id=int(data.get('latest_event_id', -1)),
selected_repository=data.get('selected_repository'),
logger.info(f'User {user_id} is connecting to conversation {conversation_id}')
conversation_store = await ConversationStore.get_instance(config)
metadata = await conversation_store.get_metadata(conversation_id)
if metadata.github_user_id != user_id:
logger.error(
f'User {user_id} is not allowed to join conversation {conversation_id}'
)
raise ConnectionRefusedError(
f'User {user_id} is not allowed to join conversation {conversation_id}'
)
return
logger.info(f'sio:oh_action:{connection_id}')
await session_manager.send_to_event_stream(connection_id, data)
try:
event_stream = await session_manager.join_conversation(
conversation_id, connection_id
)
except ConversationDoesNotExistError:
logger.error(f'Conversation {conversation_id} does not exist')
raise ConnectionRefusedError(f'Conversation {conversation_id} does not exist')
async def init_connection(
connection_id: str,
token: str | None,
github_token: str | None,
session_init_args: dict,
latest_event_id: int,
selected_repository: str | None,
):
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
settings = await settings_store.load()
if settings:
session_init_args = {**settings.__dict__, **session_init_args}
session_init_args['github_token'] = github_token
session_init_args['selected_repository'] = selected_repository
session_init_data = SessionInitData(**session_init_args)
if token:
sid = get_sid_from_token(token, config.jwt_secret)
if sid == '':
await sio.emit('oh_event', {'error': 'Invalid token', 'error_code': 401})
return
logger.info(f'Existing session: {sid}')
else:
sid = connection_id
logger.info(f'New session: {sid}')
token = sign_token({'sid': sid}, config.jwt_secret)
await sio.emit('oh_event', {'token': token, 'status': 'ok'}, to=connection_id)
# The session in question should exist, but may not actually be running locally...
event_stream = await session_manager.init_or_join_session(
sid, connection_id, session_init_data
)
# Send events
agent_state_changed = None
async_stream = AsyncEventStreamWrapper(event_stream, latest_event_id + 1)
async for event in async_stream:
@ -98,6 +79,11 @@ async def init_connection(
await sio.emit('oh_event', event_to_dict(agent_state_changed), to=connection_id)
@sio.event
async def oh_action(connection_id: str, data: dict):
await session_manager.send_to_event_stream(connection_id, data)
@sio.event
async def disconnect(connection_id: str):
logger.info(f'sio:disconnect:{connection_id}')

View File

@ -4,15 +4,13 @@ from datetime import datetime, timedelta
from typing import Callable
from urllib.parse import urlparse
from fastapi import APIRouter, Request, status
from fastapi import Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
from openhands.core.logger import openhands_logger as logger
from openhands.server.auth import get_sid_from_token
from openhands.server.shared import config, session_manager
from openhands.server.shared import session_manager
from openhands.server.types import SessionMiddlewareInterface
@ -108,11 +106,9 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
return await call_next(request)
class AttachSessionMiddleware(SessionMiddlewareInterface):
def __init__(self, app, target_router: APIRouter):
class AttachConversationMiddleware(SessionMiddlewareInterface):
def __init__(self, app):
self.app = app
self.target_router = target_router
self.target_paths = {route.path for route in target_router.routes}
def _should_attach(self, request) -> bool:
"""
@ -120,26 +116,24 @@ class AttachSessionMiddleware(SessionMiddlewareInterface):
"""
if request.method == 'OPTIONS':
return False
if request.url.path not in self.target_paths:
conversation_id = ''
if request.url.path.startswith('/api/conversation'):
# FIXME: we should be able to use path_params
path_parts = request.url.path.split('/')
if len(path_parts) > 3:
conversation_id = request.url.path.split('/')[3]
if not conversation_id:
return False
request.state.sid = conversation_id
return True
async def _attach_session(self, request: Request) -> JSONResponse | None:
async def _attach_conversation(self, request: Request) -> JSONResponse | None:
"""
Attach the user's session based on the provided authentication token.
"""
auth_token = request.headers.get('Authorization', '')
if 'Bearer' in auth_token:
auth_token = auth_token.split('Bearer')[1].strip()
request.state.sid = get_sid_from_token(auth_token, config.jwt_secret)
if not request.state.sid:
logger.warning('Invalid token')
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Invalid token'},
)
request.state.conversation = await session_manager.attach_to_conversation(
request.state.sid
)
@ -160,7 +154,7 @@ class AttachSessionMiddleware(SessionMiddlewareInterface):
if not self._should_attach(request):
return await call_next(request)
response = await self._attach_session(request)
response = await self._attach_conversation(request)
if response:
return response

View File

@ -4,10 +4,10 @@ from fastapi.responses import JSONResponse
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.base import Runtime
app = APIRouter(prefix='/api')
app = APIRouter(prefix='/api/conversations/{conversation_id}')
@app.get('/conversation')
@app.get('/config')
async def get_remote_runtime_config(request: Request):
"""Retrieve the runtime configuration.

View File

@ -5,14 +5,13 @@ from openhands.core.logger import openhands_logger as logger
from openhands.events.serialization import event_to_dict
from openhands.events.stream import AsyncEventStreamWrapper
from openhands.server.data_models.feedback import FeedbackDataModel, store_feedback
from openhands.server.shared import config
from openhands.utils.async_utils import call_sync_from_async
app = APIRouter(prefix='/api')
app = APIRouter(prefix='/api/conversations/{conversation_id}')
@app.post('/submit-feedback')
async def submit_feedback(request: Request):
async def submit_feedback(request: Request, conversation_id: str):
"""Submit user feedback.
This function stores the provided feedback data.
@ -57,18 +56,3 @@ async def submit_feedback(request: Request):
return JSONResponse(
status_code=500, content={'error': 'Failed to submit feedback'}
)
@app.get('/api/defaults')
async def appconfig_defaults():
"""Retrieve the default configuration settings.
To get the default configurations:
```sh
curl http://localhost:3000/api/defaults
```
Returns:
dict: The default configuration settings.
"""
return config.defaults_dict

View File

@ -33,11 +33,11 @@ from openhands.server.file_config import (
)
from openhands.utils.async_utils import call_sync_from_async
app = APIRouter(prefix='/api')
app = APIRouter(prefix='/api/conversations/{conversation_id}')
@app.get('/list-files')
async def list_files(request: Request, path: str | None = None):
async def list_files(request: Request, conversation_id: str, path: str | None = None):
"""List files in the specified path.
This function retrieves a list of files from the agent's runtime file store,
@ -149,7 +149,7 @@ async def select_file(file: str, request: Request):
@app.post('/upload-files')
async def upload_file(request: Request, files: list[UploadFile]):
async def upload_file(request: Request, conversation_id: str, files: list[UploadFile]):
"""Upload a list of files to the workspace.
To upload a files:
@ -311,7 +311,9 @@ async def save_file(request: Request):
@app.get('/zip-directory')
async def zip_current_workspace(request: Request, background_tasks: BackgroundTasks):
async def zip_current_workspace(
request: Request, conversation_id: str, background_tasks: BackgroundTasks
):
try:
logger.debug('Zipping workspace')
runtime: Runtime = request.state.conversation.runtime

View File

@ -0,0 +1,74 @@
import uuid
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from github import Github
from pydantic import BaseModel
from openhands.core.logger import openhands_logger as logger
from openhands.server.routes.settings import SettingsStoreImpl
from openhands.server.session.session_init_data import SessionInitData
from openhands.server.shared import config, session_manager
from openhands.storage.conversation.conversation_store import (
ConversationMetadata,
ConversationStore,
)
from openhands.utils.async_utils import call_sync_from_async
app = APIRouter(prefix='/api')
class InitSessionRequest(BaseModel):
github_token: str | None = None
latest_event_id: int = -1
selected_repository: str | None = None
args: dict | None = None
@app.post('/conversations')
async def new_conversation(request: Request, data: InitSessionRequest):
"""Initialize a new session or join an existing one.
After successful initialization, the client should connect to the WebSocket
using the returned conversation ID
"""
github_token = ''
if data.github_token:
github_token = data.github_token
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
settings = await settings_store.load()
session_init_args: dict = {}
if settings:
session_init_args = {**settings.__dict__, **session_init_args}
if data.args:
for key, value in data.args.items():
session_init_args[key.lower()] = value
session_init_args['github_token'] = github_token
session_init_args['selected_repository'] = data.selected_repository
session_init_data = SessionInitData(**session_init_args)
conversation_store = await ConversationStore.get_instance(config)
conversation_id = uuid.uuid4().hex
while await conversation_store.exists(conversation_id):
logger.warning(f'Collision on conversation ID: {conversation_id}. Retrying...')
conversation_id = uuid.uuid4().hex
user_id = ''
if data.github_token:
g = Github(data.github_token)
gh_user = await call_sync_from_async(g.get_user)
user_id = gh_user.id
await conversation_store.save_metadata(
ConversationMetadata(
conversation_id=conversation_id,
github_user_id=user_id,
selected_repository=data.selected_repository,
)
)
await session_manager.start_agent_loop(conversation_id, session_init_data)
return JSONResponse(content={'status': 'ok', 'conversation_id': conversation_id})

View File

@ -4,7 +4,7 @@ from fastapi import (
Request,
)
app = APIRouter(prefix='/api')
app = APIRouter(prefix='/api/conversations/{conversation_id}')
@app.route('/security/{path:path}', methods=['GET', 'POST', 'PUT', 'DELETE'])

View File

@ -221,6 +221,11 @@ class AgentSession:
headless_mode=False,
)
# FIXME: this sleep is a terrible hack.
# This is to give the websocket a second to connect, so that
# the status messages make it through to the frontend.
# We should find a better way to plumb status messages through.
await asyncio.sleep(1)
try:
await self.runtime.connect()
except AgentRuntimeUnavailableError as e:

View File

@ -19,6 +19,10 @@ _REDIS_POLL_TIMEOUT = 1.5
_CHECK_ALIVE_INTERVAL = 15
class ConversationDoesNotExistError(Exception):
pass
@dataclass
class SessionManager:
sio: socketio.AsyncServer
@ -170,6 +174,25 @@ class SessionManager:
self._active_conversations[sid] = (c, 1)
return c
async def join_conversation(self, sid: str, connection_id: str) -> EventStream:
await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
self.local_connection_id_to_session_id[connection_id] = sid
# If we have a local session running, use that
session = self.local_sessions_by_sid.get(sid)
if session:
logger.info(f'found_local_session:{sid}')
return session.agent_session.event_stream
# If there is a remote session running, retrieve existing events for that
redis_client = self._get_redis_client()
if redis_client and await self._is_session_running_in_cluster(sid):
return EventStream(sid, self.file_store)
raise ConversationDoesNotExistError(
f'no_conversation_for_id:{connection_id}:{sid}'
)
async def detach_from_conversation(self, conversation: Conversation):
sid = conversation.sid
async with self._conversations_lock:
@ -203,25 +226,6 @@ class SessionManager:
logger.warning('error_cleaning_detached_conversations', exc_info=True)
await asyncio.sleep(15)
async def init_or_join_session(
self, sid: str, connection_id: str, session_init_data: SessionInitData
):
await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
self.local_connection_id_to_session_id[connection_id] = sid
# If we have a local session running, use that
session = self.local_sessions_by_sid.get(sid)
if session:
logger.info(f'found_local_session:{sid}')
return session.agent_session.event_stream
# If there is a remote session running, retrieve existing events for that
redis_client = self._get_redis_client()
if redis_client and await self._is_session_running_in_cluster(sid):
return EventStream(sid, self.file_store)
return await self.start_local_session(sid, session_init_data)
async def _is_session_running_in_cluster(self, sid: str) -> bool:
"""As the rest of the cluster if a session is running. Wait a for a short timeout for a reply"""
# Create a flag for the callback
@ -275,9 +279,8 @@ class SessionManager:
finally:
self._has_remote_connections_flags.pop(sid)
async def start_local_session(self, sid: str, session_init_data: SessionInitData):
# Start a new local session
logger.info(f'start_new_local_session:{sid}')
async def start_agent_loop(self, sid: str, session_init_data: SessionInitData):
logger.info(f'start_agent_loop:{sid}')
session = Session(
sid=sid, file_store=self.file_store, config=self.config, sio=self.sio
)

View File

@ -35,8 +35,3 @@ class OpenhandsConfigInterface(ABC):
async def get_config(self) -> dict[str, str]:
"""Configure attributes for frontend"""
raise NotImplementedError
@abstractmethod
async def github_auth(self, data: dict) -> None:
"""Handle GitHub authentication."""
raise NotImplementedError

View File

@ -0,0 +1,42 @@
import json
from dataclasses import dataclass
from openhands.core.config.app_config import AppConfig
from openhands.storage import get_file_store
from openhands.storage.files import FileStore
from openhands.storage.locations import get_conversation_metadata_file
@dataclass
class ConversationMetadata:
conversation_id: str
github_user_id: str
selected_repository: str | None
@dataclass
class ConversationStore:
file_store: FileStore
async def save_metadata(self, metadata: ConversationMetadata):
json_str = json.dumps(metadata.__dict__)
path = get_conversation_metadata_file(metadata.conversation_id)
self.file_store.write(path, json_str)
async def get_metadata(self, conversation_id: str) -> ConversationMetadata:
path = get_conversation_metadata_file(conversation_id)
json_str = self.file_store.read(path)
return ConversationMetadata(**json.loads(json_str))
async def exists(self, conversation_id: str) -> bool:
path = get_conversation_metadata_file(conversation_id)
try:
self.file_store.read(path)
return True
except FileNotFoundError:
return False
@classmethod
async def get_instance(cls, config: AppConfig):
file_store = get_file_store(config.file_store, config.file_store_path)
return ConversationStore(file_store)

View File

@ -0,0 +1,17 @@
CONVERSATION_BASE_DIR = 'sessions'
def get_conversation_dir(sid: str) -> str:
return f'{CONVERSATION_BASE_DIR}/{sid}/'
def get_conversation_events_dir(sid: str) -> str:
return f'{get_conversation_dir(sid)}events/'
def get_conversation_event_file(sid: str, id: int) -> str:
return f'{get_conversation_events_dir(sid)}{id}.json'
def get_conversation_metadata_file(sid: str) -> str:
return f'{get_conversation_dir(sid)}metadata.json'

View File

@ -3,12 +3,14 @@ import os
from openhands.core.logger import openhands_logger as logger
from openhands.storage.files import FileStore
IN_MEMORY_FILES: dict = {}
class InMemoryFileStore(FileStore):
files: dict[str, str]
def __init__(self):
self.files = {}
self.files = IN_MEMORY_FILES
def write(self, path: str, contents: str) -> None:
self.files[path] = contents

View File

@ -100,9 +100,8 @@ async def test_init_new_local_session():
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
await session_manager.init_or_join_session(
'new-session-id', 'new-session-id', SessionInitData()
)
await session_manager.start_agent_loop('new-session-id', SessionInitData())
await session_manager.join_conversation('new-session-id', 'new-session-id')
assert session_instance.initialize_agent.call_count == 1
assert sio.enter_room.await_count == 1
@ -131,14 +130,9 @@ async def test_join_local_session():
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
# First call initializes
await session_manager.init_or_join_session(
'new-session-id', 'new-session-id', SessionInitData()
)
# Second call joins
await session_manager.init_or_join_session(
'new-session-id', 'extra-connection-id', SessionInitData()
)
await session_manager.start_agent_loop('new-session-id', SessionInitData())
await session_manager.join_conversation('new-session-id', 'new-session-id')
await session_manager.join_conversation('new-session-id', 'new-session-id')
assert session_instance.initialize_agent.call_count == 1
assert sio.enter_room.await_count == 2
@ -167,10 +161,7 @@ async def test_join_cluster_session():
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
# First call initializes
await session_manager.init_or_join_session(
'new-session-id', 'new-session-id', SessionInitData()
)
await session_manager.join_conversation('new-session-id', 'new-session-id')
assert session_instance.initialize_agent.call_count == 0
assert sio.enter_room.await_count == 1
@ -199,9 +190,8 @@ async def test_add_to_local_event_stream():
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
await session_manager.init_or_join_session(
'new-session-id', 'connection-id', SessionInitData()
)
await session_manager.start_agent_loop('new-session-id', SessionInitData())
await session_manager.join_conversation('new-session-id', 'connection-id')
await session_manager.send_to_event_stream(
'connection-id', {'event_type': 'some_event'}
)
@ -232,9 +222,7 @@ async def test_add_to_cluster_event_stream():
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
await session_manager.init_or_join_session(
'new-session-id', 'connection-id', SessionInitData()
)
await session_manager.join_conversation('new-session-id', 'connection-id')
await session_manager.send_to_event_stream(
'connection-id', {'event_type': 'some_event'}
)

View File

@ -26,9 +26,6 @@ def event_stream(temp_dir):
event_stream = EventStream('asdf', file_store)
yield event_stream
# clear after each test
event_stream.clear()
@pytest.fixture
def agent_configs():

View File

@ -19,6 +19,10 @@ class _StorageTest(ABC):
store: FileStore
def get_store(self) -> FileStore:
try:
self.store.delete('')
except Exception:
pass
return self.store
def test_basic_fileops(self):