diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 415dceafa5fb83..3a373085979e7c 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -22,6 +22,16 @@
+
+
+
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 3db8e3375c184e..52daf8e8b893e9 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -5,7 +5,9 @@
+
+
-
+
\ No newline at end of file
diff --git a/components/ide/jetbrains/toolbox/.gitattributes b/components/ide/jetbrains/toolbox/.gitattributes
new file mode 100644
index 00000000000000..afd59d8fce15d0
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/.gitattributes
@@ -0,0 +1,8 @@
+#
+# https://help.github.com/articles/dealing-with-line-endings/
+#
+# Linux start script should use lf
+/gradlew text eol=lf
+
+# These are Windows script files and should use crlf
+*.bat text eol=crlf
diff --git a/components/ide/jetbrains/toolbox/.gitignore b/components/ide/jetbrains/toolbox/.gitignore
new file mode 100644
index 00000000000000..83d0ea8e397220
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/.gitignore
@@ -0,0 +1,6 @@
+# Gradle
+.gradle
+build
+
+# IntelliJ IDEA
+.idea
diff --git a/components/ide/jetbrains/toolbox/README.md b/components/ide/jetbrains/toolbox/README.md
new file mode 100644
index 00000000000000..869005a898825d
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/README.md
@@ -0,0 +1,17 @@
+# Gitpod Toolbox Plugin
+
+To load plugin into the provided Toolbox App, run `./gradlew build copyPlugin`
+
+or put files in the following directory:
+
+* Windows: `%LocalAppData%/JetBrains/Toolbox/cache/plugins/plugin-id`
+* macOS: `~/Library/Caches/JetBrains/Toolbox/plugins/plugin-id`
+* Linux: `~/.local/share/JetBrains/Toolbox/plugins/plugin-id`
+
+
+## How to Develop
+
+- Open the Toolbox App in debug mode
+```bash
+TOOLBOX_DEV_DEBUG_SUSPEND=true && open /Applications/JetBrains\ Toolbox.app
+```
diff --git a/components/ide/jetbrains/toolbox/build.gradle.kts b/components/ide/jetbrains/toolbox/build.gradle.kts
new file mode 100644
index 00000000000000..35d9a1e7eb6ae0
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/build.gradle.kts
@@ -0,0 +1,201 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+import com.github.jk1.license.filter.ExcludeTransitiveDependenciesFilter
+import com.github.jk1.license.render.JsonReportRenderer
+import org.jetbrains.intellij.pluginRepository.PluginRepositoryFactory
+import org.jetbrains.intellij.pluginRepository.model.LicenseUrl
+import org.jetbrains.intellij.pluginRepository.model.ProductFamily
+import org.jetbrains.kotlin.com.intellij.openapi.util.SystemInfoRt
+import java.nio.file.Path
+import kotlin.io.path.div
+
+plugins {
+ alias(libs.plugins.kotlin)
+ alias(libs.plugins.serialization)
+ `java-library`
+ alias(libs.plugins.dependency.license.report)
+ id("com.github.johnrengelman.shadow") version "8.1.1"
+}
+
+buildscript {
+ dependencies {
+ classpath(libs.marketplace.client)
+ }
+}
+
+repositories {
+ mavenCentral()
+ maven("https://packages.jetbrains.team/maven/p/tbx/gateway")
+}
+
+dependencies {
+ implementation(project(":supervisor-api"))
+ implementation(project(":gitpod-publicapi"))
+
+ // com.connectrpc https://mvnrepository.com/artifact/com.connectrpc
+ // connect rpc dependencies
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("com.connectrpc:connect-kotlin-okhttp:0.6.0")
+ implementation("com.connectrpc:connect-kotlin:0.6.0")
+ // Java specific dependencies.
+ implementation("com.connectrpc:connect-kotlin-google-java-ext:0.6.0")
+ implementation("com.google.protobuf:protobuf-java:4.27.2")
+ // WebSocket
+ compileOnly("javax.websocket:javax.websocket-api:1.1")
+ compileOnly("org.eclipse.jetty.websocket:websocket-api:9.4.54.v20240208")
+ implementation("org.eclipse.jetty.websocket:javax-websocket-client-impl:9.4.54.v20240208")
+ // RD-Core https://mvnrepository.com/artifact/com.jetbrains.rd/rd-core
+ implementation("com.jetbrains.rd:rd-core:2024.1.1")
+
+ implementation(libs.gateway.api)
+ implementation(libs.slf4j)
+ implementation(libs.bundles.serialization)
+ implementation(libs.coroutines.core)
+ implementation(libs.okhttp)
+}
+
+
+val pluginId = "io.gitpod.toolbox.gateway"
+val pluginVersion = "0.0.1-dev"
+
+tasks.shadowJar {
+ archiveBaseName.set(pluginId)
+ archiveVersion.set(pluginVersion)
+
+ val excludedGroups = listOf(
+ "com.jetbrains.toolbox.gateway",
+ "com.jetbrains",
+ "org.jetbrains",
+ "com.squareup.okhttp3",
+ "org.slf4j",
+ "org.jetbrains.intellij",
+ "com.squareup.okio",
+ "kotlin."
+ )
+
+ val includeGroups = listOf(
+ "com.jetbrains.rd"
+ )
+
+ dependencies {
+ exclude {
+ excludedGroups.any { group ->
+ if (includeGroups.any { includeGroup -> it.name.startsWith(includeGroup) }) {
+ return@any false
+ }
+ it.name.startsWith(group)
+ }
+ }
+ }
+}
+
+licenseReport {
+ renderers = arrayOf(JsonReportRenderer("dependencies.json"))
+ filters = arrayOf(ExcludeTransitiveDependenciesFilter())
+}
+
+tasks.compileKotlin {
+ kotlinOptions.freeCompilerArgs += listOf(
+ "-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
+ )
+}
+
+val restartToolbox by tasks.creating {
+ group = "01.Gitpod"
+ description = "Restarts the JetBrains Toolbox app."
+
+ doLast {
+ when {
+ SystemInfoRt.isMac -> {
+ exec {
+ commandLine("sh", "-c", "pkill -f 'JetBrains Toolbox' || true")
+ }
+ Thread.sleep(3000)
+ exec {
+ commandLine("sh", "-c", "echo debugClean > ~/Library/Logs/JetBrains/Toolbox/toolbox.log")
+ }
+ exec {
+// environment("TOOLBOX_DEV_DEBUG_SUSPEND", "true")
+ commandLine("open", "/Applications/JetBrains Toolbox.app")
+ }
+ }
+
+ else -> {
+ println("restart Toolbox to make plugin works.")
+ }
+ }
+ }
+}
+
+val copyPlugin by tasks.creating(Sync::class.java) {
+ group = "01.Gitpod"
+
+ dependsOn(tasks.named("shadowJar"))
+ from(tasks.named("shadowJar").get().outputs.files)
+
+ val userHome = System.getProperty("user.home").let { Path.of(it) }
+ val toolboxCachesDir = when {
+ SystemInfoRt.isWindows -> System.getenv("LOCALAPPDATA")?.let { Path.of(it) } ?: (userHome / "AppData" / "Local")
+ // currently this is the location that TBA uses on Linux
+ SystemInfoRt.isLinux -> System.getenv("XDG_DATA_HOME")?.let { Path.of(it) } ?: (userHome / ".local" / "share")
+ SystemInfoRt.isMac -> userHome / "Library" / "Caches"
+ else -> error("Unknown os")
+ } / "JetBrains" / "Toolbox"
+
+ val pluginsDir = when {
+ SystemInfoRt.isWindows -> toolboxCachesDir / "cache"
+ SystemInfoRt.isLinux || SystemInfoRt.isMac -> toolboxCachesDir
+ else -> error("Unknown os")
+ } / "plugins"
+
+ val targetDir = pluginsDir / pluginId
+
+ from("src/main/resources") {
+ include("extension.json")
+ include("dependencies.json")
+ include("icon.svg")
+ }
+
+ into(targetDir)
+
+ finalizedBy(restartToolbox)
+}
+
+val pluginZip by tasks.creating(Zip::class) {
+ dependsOn(tasks.named("shadowJar"))
+ from(tasks.named("shadowJar").get().outputs.files)
+
+ from("src/main/resources") {
+ include("extension.json")
+ include("dependencies.json")
+ }
+ from("src/main/resources") {
+ include("icon.svg")
+ rename("icon.svg", "pluginIcon.svg")
+ }
+ archiveBaseName.set("$pluginId-$pluginVersion")
+}
+
+val uploadPlugin by tasks.creating {
+ dependsOn(pluginZip)
+
+ doLast {
+ val token = System.getenv("JB_MARKETPLACE_PUBLISH_TOKEN")
+ val instance = PluginRepositoryFactory.create("https://plugins.jetbrains.com", token)
+
+ // first upload
+ // instance.uploader.uploadNewPlugin(
+ // pluginZip.outputs.files.singleFile,
+ // listOf("toolbox", "gateway", "gitpod"),
+ // LicenseUrl.GNU_LESSER,
+ // ProductFamily.TOOLBOX,
+ // "Gitpod",
+ // "dev"
+ // )
+
+ // subsequent updates
+// instance.uploader.upload(pluginId, pluginZip.outputs.files.singleFile)
+ }
+}
diff --git a/components/ide/jetbrains/toolbox/gradle.properties b/components/ide/jetbrains/toolbox/gradle.properties
new file mode 100644
index 00000000000000..9d45e708410982
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/gradle.properties
@@ -0,0 +1,4 @@
+pluginVersion=0.0.1
+environmentName=latest
+supervisorApiProjectPath=../../../supervisor-api/java
+gitpodPublicApiProjectPath=../../../public-api/java
diff --git a/components/ide/jetbrains/toolbox/gradle/libs.versions.toml b/components/ide/jetbrains/toolbox/gradle/libs.versions.toml
new file mode 100644
index 00000000000000..91868634b2de68
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/gradle/libs.versions.toml
@@ -0,0 +1,30 @@
+[versions]
+gateway = "2.5.0.32871"
+#gateway = "2.4.0.31544"
+kotlin = "1.9.0"
+coroutines = "1.7.3"
+serialization = "1.5.0"
+okhttp = "4.10.0"
+slf4j = "2.0.3"
+dependency-license-report = "2.5"
+marketplace-client = "2.0.38"
+
+[libraries]
+kotlin-stdlib = { module = "com.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
+gateway-api = { module = "com.jetbrains.toolbox.gateway:gateway-api", version.ref = "gateway" }
+coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
+serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "serialization" }
+serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
+serialization-json-okio = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-okio", version.ref = "serialization" }
+okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
+slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
+
+marketplace-client = { module = "org.jetbrains.intellij:plugin-repository-rest-client", version.ref = "marketplace-client" }
+
+[bundles]
+serialization = [ "serialization-core", "serialization-json", "serialization-json-okio" ]
+
+[plugins]
+kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
+serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+dependency-license-report = { id = "com.github.jk1.dependency-license-report", version.ref = "dependency-license-report" }
diff --git a/components/ide/jetbrains/toolbox/gradle/wrapper/gradle-wrapper.jar b/components/ide/jetbrains/toolbox/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000000000..c1962a79e29d3e
Binary files /dev/null and b/components/ide/jetbrains/toolbox/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/components/ide/jetbrains/toolbox/gradle/wrapper/gradle-wrapper.properties b/components/ide/jetbrains/toolbox/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000000000..0c85a1f7519700
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip
+networkTimeout=10000
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/components/ide/jetbrains/toolbox/gradlew b/components/ide/jetbrains/toolbox/gradlew
new file mode 100755
index 00000000000000..aeb74cbb43e393
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/gradlew
@@ -0,0 +1,245 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command;
+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+# shell script including quotes and variable substitutions, so put them in
+# double quotes to make sure that they get re-expanded; and
+# * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/components/ide/jetbrains/toolbox/gradlew.bat b/components/ide/jetbrains/toolbox/gradlew.bat
new file mode 100644
index 00000000000000..93e3f59f135dd2
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/components/ide/jetbrains/toolbox/settings.gradle.kts b/components/ide/jetbrains/toolbox/settings.gradle.kts
new file mode 100644
index 00000000000000..9732826b70f69c
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/settings.gradle.kts
@@ -0,0 +1,9 @@
+rootProject.name = "gitpod-toolbox-gateway"
+
+include(":supervisor-api")
+val supervisorApiProjectPath: String by settings
+project(":supervisor-api").projectDir = File(supervisorApiProjectPath)
+
+include(":gitpod-publicapi")
+val gitpodPublicApiProjectPath: String by settings
+project(":gitpod-publicapi").projectDir = File(gitpodPublicApiProjectPath)
diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/auth/GitpodAuthManager.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/auth/GitpodAuthManager.kt
new file mode 100644
index 00000000000000..28b9e4be7fdf6c
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/auth/GitpodAuthManager.kt
@@ -0,0 +1,236 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+package io.gitpod.toolbox.auth
+
+import com.connectrpc.Code
+import com.connectrpc.ConnectException
+import com.jetbrains.toolbox.gateway.auth.*
+import io.gitpod.publicapi.experimental.v1.UserServiceClient
+import io.gitpod.toolbox.service.GitpodPublicApiManager
+import io.gitpod.toolbox.service.Utils
+import io.gitpod.toolbox.utils.GitpodLogger
+import kotlinx.coroutines.future.future
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import java.net.URI
+import java.util.*
+import java.util.concurrent.Future
+
+// TODO: Validate Scopes
+val authScopesJetBrainsToolbox = listOf(
+ "function:getGitpodTokenScopes",
+ "function:getLoggedInUser",
+ "function:getOwnerToken",
+ "function:getWorkspace",
+ "function:getWorkspaces",
+ "function:listenForWorkspaceInstanceUpdates",
+ "function:startWorkspace",
+ "function:stopWorkspace",
+ "function:deleteWorkspace",
+ "function:getToken",
+ "resource:default",
+)
+
+class GitpodAuthManager {
+ private val manager: PluginAuthManager
+ private var loginListeners: MutableList<() -> Unit> = mutableListOf()
+ private var logoutListeners: MutableList<() -> Unit> = mutableListOf()
+
+ init {
+ manager = Utils.sharedServiceLocator.getAuthManager(
+ "gitpod",
+ GitpodAccount::class.java,
+ { it.encode() },
+ { GitpodAccount.decode(it) },
+ { oauthToken, authCfg -> getAuthenticatedUser(authCfg.baseUrl, oauthToken) },
+ { oauthToken, gpAccount -> getAuthenticatedUser(gpAccount.getHost(), oauthToken) },
+ { gpLoginCfg ->
+ val authParams = mapOf(
+ "response_type" to "code",
+ "client_id" to "toolbox-gateway-gitpod-plugin",
+ "scope" to authScopesJetBrainsToolbox.joinToString(" "),
+ )
+ val tokenParams =
+ mapOf("grant_type" to "authorization_code", "client_id" to "toolbox-gateway-gitpod-plugin")
+ AuthConfiguration(
+ authParams,
+ tokenParams,
+ gpLoginCfg.host,
+ gpLoginCfg.host + "/api/oauth/authorize",
+ gpLoginCfg.host + "/api/oauth/token",
+ "code_challenge",
+ "S256",
+ "code_verifier",
+ "Bearer"
+ )
+ },
+ { RefreshConfiguration("", mapOf(), "", ContentType.JSON) },
+ )
+
+ manager.addEventListener {
+ when (it.type) {
+ AuthEvent.Type.LOGIN -> {
+ GitpodLogger.info(" user logged in ${it.accountId}")
+ resetCurrentAccount(it.accountId)
+ loginListeners.forEach { it() }
+ }
+
+ AuthEvent.Type.LOGOUT -> {
+ GitpodLogger.info("user logged out ${it.accountId}")
+ resetCurrentAccount(it.accountId)
+ logoutListeners.forEach { it() }
+ }
+ }
+ }
+ }
+
+ private fun resetCurrentAccount(accountId: String) {
+ val account = manager.accountsWithStatus.find { it.account.id == accountId }?.account ?: return
+ GitpodLogger.debug("reset settings for ${account.getHost()}")
+ Utils.gitpodSettings.resetSettings(account.getHost())
+ }
+
+ fun getCurrentAccount(): GitpodAccount? {
+ return manager.accountsWithStatus.find { it.account.getHost() == Utils.gitpodSettings.gitpodHost }?.account
+ }
+
+ suspend fun loginWithHost(host: String): Boolean {
+ val currentAccount = getCurrentAccount()
+ if (currentAccount?.getHost() == host) {
+ if (currentAccount.isValidate()) {
+ return true
+ } else {
+ manager.logout(currentAccount.id)
+ Utils.openUrl(this.getOAuthLoginUrl(host))
+ return false
+ }
+ }
+ val account = manager.accountsWithStatus.find { it.account.getHost() == host }?.account
+ if (account != null) {
+ if (account.isValidate()) {
+ Utils.gitpodSettings.gitpodHost = host
+ loginListeners.forEach { it() }
+ return true
+ } else {
+ manager.logout(account.id)
+ Utils.openUrl(this.getOAuthLoginUrl(host))
+ return false
+ }
+ }
+ Utils.openUrl(this.getOAuthLoginUrl(host))
+ return false
+ }
+
+ fun logout() {
+ getCurrentAccount()?.let { manager.logout(it.id) }
+ }
+
+ fun getOAuthLoginUrl(gitpodHost: String): String {
+ GitpodLogger.info("get oauth url of $gitpodHost")
+ return manager.initiateLogin(GitpodLoginConfiguration(gitpodHost))
+ }
+
+ fun tryHandle(uri: URI): Boolean {
+ if (!this.manager.canHandle(uri)) {
+ return false
+ }
+ Utils.toolboxUi.showWindow()
+ this.manager.handle(uri)
+ return true
+ }
+
+ fun addLoginListener(listener: () -> Unit) {
+ loginListeners.add(listener)
+ }
+
+ fun addLogoutListener(listener: () -> Unit) {
+ logoutListeners.add(listener)
+ }
+
+ private fun getAuthenticatedUser(gitpodHost: String, oAuthToken: OAuthToken): Future {
+ return Utils.coroutineScope.future {
+ val bearerToken = getBearerToken(oAuthToken)
+ val client = GitpodPublicApiManager.createClient(URI(gitpodHost).host, bearerToken)
+ val user = GitpodPublicApiManager.tryGetAuthenticatedUser(UserServiceClient(client))
+ GitpodAccount(bearerToken, user.id, user.name, gitpodHost)
+ }
+ }
+
+ private fun getBearerToken(oAuthToken: OAuthToken): String {
+ val parts = oAuthToken.authorizationHeader.replace("Bearer ", "").split(".")
+ // We don't validate jwt token
+ if (parts.size != 3) {
+ throw IllegalArgumentException("Invalid JWT")
+ }
+ val decoded = String(Base64.getUrlDecoder().decode(parts[1].toByteArray()))
+ val jsonElement = Json.parseToJsonElement(decoded)
+ val payloadMap = jsonElement.jsonObject.mapValues {
+ it.value.jsonPrimitive.content
+ }
+ return payloadMap["jti"] ?: throw IllegalArgumentException("Failed to parse JWT token")
+ }
+
+}
+
+class GitpodLoginConfiguration(val host: String)
+
+@Serializable
+class GitpodAccount : Account {
+ private val credentials: String
+ private val id: String
+ private val name: String
+ private val host: String
+
+ constructor(credentials: String, id: String, name: String, host: String) {
+ this.credentials = credentials
+ this.id = id
+ this.name = name
+ this.host = URI(host).host
+ }
+
+ override fun getId() = id
+ override fun getFullName() = name
+ fun getCredentials() = credentials
+ fun getHost() = host
+
+ fun encode(): String {
+ return Json.encodeToString(this)
+ }
+
+ suspend fun isValidate(): Boolean {
+ // TODO(hw): Align host formatting everywhere
+ val host = if (host.startsWith("http")) {
+ host
+ } else {
+ "https://$host"
+ }
+ val client = GitpodPublicApiManager.createClient(URI(host).host, credentials)
+ GitpodLogger.debug("validating account $host")
+ try {
+ GitpodPublicApiManager.tryGetAuthenticatedUser(UserServiceClient(client))
+ return true
+ } catch (e: ConnectException) {
+ // TODO(hw): Server close jsonrpc so papi server respond internal error
+ if (e.code == Code.UNAUTHENTICATED || (e.code == Code.INTERNAL_ERROR && e.message != null && e.message!!.contains(
+ "jsonrpc2: connection is closed"
+ ))
+ ) {
+ GitpodLogger.error("account $host is not valid")
+ return false
+ }
+ return true
+ }
+ }
+
+ companion object {
+ fun decode(str: String): GitpodAccount {
+ return Json.decodeFromString(str)
+ }
+ }
+}
diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/auth/GitpodLoginPage.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/auth/GitpodLoginPage.kt
new file mode 100644
index 00000000000000..110caf92b5e6c1
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/auth/GitpodLoginPage.kt
@@ -0,0 +1,43 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+package io.gitpod.toolbox.auth
+
+import com.jetbrains.toolbox.gateway.ui.*
+import io.gitpod.toolbox.components.AbstractUiPage
+import io.gitpod.toolbox.components.GitpodIcon
+import io.gitpod.toolbox.components.SimpleButton
+import io.gitpod.toolbox.service.Utils
+
+class GitpodLoginPage(private val authManager: GitpodAuthManager) : AbstractUiPage() {
+ private val hostField = TextField("Host", "https://exp-migration.preview.gitpod-dev.com", null) {
+ if (it.isBlank()) {
+ ValidationResult.Invalid("Host should not be empty")
+ }
+ if (!it.startsWith("https://")) {
+ ValidationResult.Invalid("Host should start with https://")
+ }
+ ValidationResult.Valid
+ }
+
+ override fun getFields(): MutableList {
+ return mutableListOf(hostField, LinkField("Learn more", "https://gitpod.io/docs"))
+ }
+
+ override fun getActionButtons(): MutableList {
+ return mutableListOf(SimpleButton("Login") action@{
+ val host = getFieldValue(hostField) ?: return@action
+ val url = authManager.getOAuthLoginUrl(host)
+ Utils.openUrl(url)
+ })
+ }
+
+ override fun getTitle() = "Login to Gitpod"
+
+ override fun getDescription() = "Always ready to code."
+
+ override fun getSvgIcon(): ByteArray {
+ return GitpodIcon()
+ }
+}
diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/AbstractUiPage.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/AbstractUiPage.kt
new file mode 100644
index 00000000000000..0e7f53f50bd2c9
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/AbstractUiPage.kt
@@ -0,0 +1,22 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+package io.gitpod.toolbox.components
+
+import com.jetbrains.toolbox.gateway.ui.UiField
+import com.jetbrains.toolbox.gateway.ui.UiPage
+import java.util.function.BiConsumer
+import java.util.function.Function
+
+abstract class AbstractUiPage : UiPage {
+ private var stateGetter: Function? = null
+
+ @Suppress("UNCHECKED_CAST")
+ fun getFieldValue(field: UiField) = stateGetter?.apply(field) as T?
+
+ override fun setStateAccessor(setter: BiConsumer?, getter: Function?) {
+ super.setStateAccessor(setter, getter)
+ stateGetter = getter
+ }
+}
diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/Button.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/Button.kt
new file mode 100644
index 00000000000000..2814128842a584
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/Button.kt
@@ -0,0 +1,16 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+package io.gitpod.toolbox.components
+
+import com.jetbrains.toolbox.gateway.ui.RunnableActionDescription
+
+open class SimpleButton(private val title: String, private val action: () -> Unit = {}): RunnableActionDescription {
+ override fun getLabel(): String {
+ return title
+ }
+ override fun run() {
+ action()
+ }
+}
diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/Icon.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/Icon.kt
new file mode 100644
index 00000000000000..2e0cacef0e5c98
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/Icon.kt
@@ -0,0 +1,12 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+package io.gitpod.toolbox.components
+
+import io.gitpod.toolbox.gateway.GitpodGatewayExtension
+
+@Suppress("FunctionName")
+fun GitpodIcon(): ByteArray {
+ return GitpodGatewayExtension::class.java.getResourceAsStream("/icon.svg")?.readAllBytes() ?: byteArrayOf()
+}
diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodGatewayExtension.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodGatewayExtension.kt
new file mode 100644
index 00000000000000..1d9565e981784d
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodGatewayExtension.kt
@@ -0,0 +1,18 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+package io.gitpod.toolbox.gateway
+
+import com.jetbrains.toolbox.gateway.GatewayExtension
+import com.jetbrains.toolbox.gateway.RemoteEnvironmentConsumer
+import com.jetbrains.toolbox.gateway.RemoteProvider
+import com.jetbrains.toolbox.gateway.ToolboxServiceLocator
+import io.gitpod.toolbox.service.Utils
+
+class GitpodGatewayExtension : GatewayExtension {
+ override fun createRemoteProviderPluginInstance(serviceLocator: ToolboxServiceLocator): RemoteProvider {
+ Utils.initialize(serviceLocator)
+ return GitpodRemoteProvider(serviceLocator.getService(RemoteEnvironmentConsumer::class.java))
+ }
+}
diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodRemoteProvider.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodRemoteProvider.kt
new file mode 100644
index 00000000000000..2d04dc30a1db54
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodRemoteProvider.kt
@@ -0,0 +1,165 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+package io.gitpod.toolbox.gateway
+
+import com.jetbrains.toolbox.gateway.ProviderVisibilityState
+import com.jetbrains.toolbox.gateway.RemoteEnvironmentConsumer
+import com.jetbrains.toolbox.gateway.RemoteProvider
+import com.jetbrains.toolbox.gateway.ui.AccountDropdownField
+import com.jetbrains.toolbox.gateway.ui.ActionDescription
+import com.jetbrains.toolbox.gateway.ui.UiPage
+import io.gitpod.publicapi.experimental.v1.Workspaces
+import io.gitpod.toolbox.auth.GitpodAuthManager
+import io.gitpod.toolbox.auth.GitpodLoginPage
+import io.gitpod.toolbox.components.GitpodIcon
+import io.gitpod.toolbox.components.SimpleButton
+import io.gitpod.toolbox.service.*
+import io.gitpod.toolbox.utils.GitpodLogger
+import kotlinx.coroutines.launch
+import java.net.URI
+import java.util.concurrent.CompletableFuture
+
+class GitpodRemoteProvider(
+ private val consumer: RemoteEnvironmentConsumer,
+) : RemoteProvider {
+ private val authManger = GitpodAuthManager()
+ private val publicApi = GitpodPublicApiManager(authManger)
+ private val loginPage = GitpodLoginPage(authManger)
+
+ // cache consumed environments map locally
+ private val environmentMap = mutableMapOf>()
+
+ private var pendingConnectParams: Pair? = null
+ private val openInToolboxUriHandler = GitpodOpenInToolboxUriHandler { (gitpodHost, connectParams) ->
+ val future = CompletableFuture()
+ Utils.coroutineScope.launch {
+ if (!authManger.loginWithHost(gitpodHost)) {
+ pendingConnectParams = gitpodHost to connectParams
+ future.complete(null)
+ return@launch
+ }
+ setEnvironmentVisibility(connectParams)
+ future.complete(null)
+ }
+ return@GitpodOpenInToolboxUriHandler future
+ }
+
+ private suspend fun setEnvironmentVisibility(connectParams: ConnectParams) {
+ val workspaceId = connectParams.workspaceId
+ GitpodLogger.debug("setEnvironmentVisibility $workspaceId, $connectParams")
+ val obj = environmentMap[connectParams.uniqueID]
+ var (workspace) = obj ?: Pair(null, null)
+ if (obj == null) {
+ workspace = publicApi.getWorkspace(workspaceId)
+ val env = GitpodRemoteProviderEnvironment(
+ authManger,
+ connectParams,
+ publicApi,
+ Utils.observablePropertiesFactory
+ )
+ environmentMap[connectParams.uniqueID] = Pair(workspace, env)
+ consumer.consumeEnvironments(environmentMap.values.map { it.second })
+ }
+ val joinLinkInfo = workspace!!.fetchJoinLink2Info(publicApi.getWorkspaceOwnerToken(workspaceId))
+ Utils.clientHelper.setAutoConnectOnEnvironmentReady(workspaceId, joinLinkInfo.ideVersion, joinLinkInfo.projectPath)
+ }
+
+ private fun showWorkspacesList() {
+ Utils.coroutineScope.launch {
+ val workspaces = publicApi.listWorkspaces()
+ if (workspaces.isEmpty()) {
+ return@launch
+ }
+ consumer.consumeEnvironments(workspaces.map {
+ val connectParams = it.getConnectParams()
+ val env = environmentMap[connectParams.uniqueID]?.second ?: GitpodRemoteProviderEnvironment(
+ authManger,
+ connectParams,
+ publicApi,
+ Utils.observablePropertiesFactory
+ )
+ environmentMap[connectParams.uniqueID] = Pair(it, env)
+ if (connectParams.uniqueID == pendingConnectParams?.second?.uniqueID) {
+ setEnvironmentVisibility(connectParams)
+ pendingConnectParams = null
+ }
+ env
+ })
+ }
+ }
+
+ private fun startup() {
+ val account = authManger.getCurrentAccount() ?: return
+ publicApi.setup()
+ GitpodLogger.info("startup with ${account.getHost()} ${account.id}")
+ showWorkspacesList()
+ }
+
+ override fun getOverrideUiPage(): UiPage? {
+ authManger.addLoginListener {
+ startup()
+ Utils.toolboxUi.showPluginEnvironmentsPage()
+ }
+ authManger.addLogoutListener {
+ Utils.toolboxUi.showPluginEnvironmentsPage()
+ }
+ val account = authManger.getCurrentAccount()
+ account ?: return loginPage
+ startup()
+ Utils.coroutineScope.launch {
+ if (account.isValidate()) {
+ return@launch
+ }
+ authManger.logout()
+ Utils.toolboxUi.showPluginEnvironmentsPage()
+ }
+ return null
+ }
+
+ override fun close() {}
+
+ override fun getName(): String = "Gitpod"
+ override fun getSvgIcon() = GitpodIcon()
+
+ override fun getNewEnvironmentUiPage() = UiPage.empty
+
+ override fun getAccountDropDown(): AccountDropdownField? {
+ val account = authManger.getCurrentAccount() ?: return null
+ return AccountDropdownField(account.fullName) {
+ authManger.logout()
+ }
+ }
+
+ override fun getAdditionalPluginActions(): MutableList {
+ return mutableListOf(
+ SimpleButton("View documents") {
+ Utils.openUrl("https://gitpod.io/docs")
+ },
+ )
+
+ }
+
+ override fun canCreateNewEnvironments(): Boolean = false
+ override fun isSingleEnvironment(): Boolean = false
+
+ override fun setVisible(visibilityState: ProviderVisibilityState) {}
+
+ override fun addEnvironmentsListener(listener: RemoteEnvironmentConsumer) {}
+ override fun removeEnvironmentsListener(listener: RemoteEnvironmentConsumer) {}
+
+ override fun handleUri(uri: URI) {
+ if (authManger.tryHandle(uri)) {
+ return
+ }
+ if (openInToolboxUriHandler.tryHandle(uri)) {
+ return
+ }
+ when (uri.path) {
+ else -> {
+ GitpodLogger.warn("Unknown request: $uri")
+ }
+ }
+ }
+}
diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodRemoteProviderEnvironment.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodRemoteProviderEnvironment.kt
new file mode 100644
index 00000000000000..809d7384f91e40
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodRemoteProviderEnvironment.kt
@@ -0,0 +1,111 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+package io.gitpod.toolbox.gateway
+
+import com.jetbrains.toolbox.gateway.AbstractRemoteProviderEnvironment
+import com.jetbrains.toolbox.gateway.EnvironmentVisibilityState
+import com.jetbrains.toolbox.gateway.environments.EnvironmentContentsView
+import com.jetbrains.toolbox.gateway.states.EnvironmentStateConsumer
+import com.jetbrains.toolbox.gateway.states.StandardRemoteEnvironmentState
+import com.jetbrains.toolbox.gateway.ui.ActionDescription
+import com.jetbrains.toolbox.gateway.ui.ObservableList
+import com.jetbrains.toolbox.gateway.ui.ObservablePropertiesFactory
+import io.gitpod.publicapi.experimental.v1.Workspaces.WorkspaceInstanceStatus
+import io.gitpod.toolbox.auth.GitpodAuthManager
+import io.gitpod.toolbox.service.ConnectParams
+import io.gitpod.toolbox.service.GitpodPublicApiManager
+import io.gitpod.toolbox.service.Utils
+import io.gitpod.toolbox.utils.GitpodLogger
+import kotlinx.coroutines.DisposableHandle
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.launch
+import java.util.concurrent.CompletableFuture
+
+class GitpodRemoteProviderEnvironment(
+ private val authManager: GitpodAuthManager,
+ private val connectParams: ConnectParams,
+ private val publicApi: GitpodPublicApiManager, observablePropertiesFactory: ObservablePropertiesFactory?,
+) : AbstractRemoteProviderEnvironment(observablePropertiesFactory), DisposableHandle {
+ private val actionList = Utils.observablePropertiesFactory.emptyObservableList();
+ private val contentsViewFuture: CompletableFuture = CompletableFuture.completedFuture(
+ GitpodSSHEnvironmentContentsView(
+ authManager,
+ connectParams,
+ publicApi,
+ )
+ )
+ private var watchWorkspaceJob: Job? = null
+
+ private val lastWSEnvState = MutableSharedFlow(1, 0, BufferOverflow.DROP_OLDEST)
+ private var lastPhase: WorkspaceInstanceStatus.Phase = WorkspaceInstanceStatus.Phase.PHASE_UNSPECIFIED
+
+ init {
+ Utils.coroutineScope.launch {
+ lastWSEnvState.collect { lastState ->
+ val state = lastState.getState()
+ val actions = mutableListOf()
+ actionList.clear()
+ actionList.addAll(actions)
+ listenerSet.forEach { it.consume(state) }
+ }
+ }
+
+ Utils.coroutineScope.launch {
+ GitpodLogger.debug("watching workspace ${connectParams.workspaceId}")
+ watchWorkspaceJob = publicApi.watchWorkspaceStatus(connectParams.workspaceId) { _, status ->
+ lastPhase = status.phase
+ GitpodLogger.debug("${connectParams.workspaceId} status updated: $lastPhase")
+ lastWSEnvState.tryEmit(WorkspaceEnvState(status.phase))
+ }
+ }
+ }
+
+ override fun addStateListener(consumer: EnvironmentStateConsumer): Boolean {
+ val ok = super.addStateListener(consumer)
+ Utils.coroutineScope.launch {
+ lastWSEnvState.tryEmit(WorkspaceEnvState(lastPhase))
+ }
+ return ok
+ }
+
+ override fun getId(): String = connectParams.uniqueID
+ override fun getName(): String = connectParams.resolvedWorkspaceId
+
+ override fun getContentsView(): CompletableFuture = contentsViewFuture
+
+ override fun setVisible(visibilityState: EnvironmentVisibilityState) {
+ }
+
+ override fun getActionList(): ObservableList = actionList
+
+ override fun dispose() {
+ watchWorkspaceJob?.cancel()
+ }
+}
+
+
+private class WorkspaceEnvState(val phase: WorkspaceInstanceStatus.Phase) {
+
+ fun getState() = run {
+ phaseToStateMap[phase] ?: StandardRemoteEnvironmentState.Unreachable
+ }
+
+ companion object {
+ val phaseToStateMap = mapOf(
+ WorkspaceInstanceStatus.Phase.PHASE_UNSPECIFIED to StandardRemoteEnvironmentState.Unreachable,
+ WorkspaceInstanceStatus.Phase.PHASE_PREPARING to StandardRemoteEnvironmentState.Initializing,
+ WorkspaceInstanceStatus.Phase.PHASE_IMAGEBUILD to StandardRemoteEnvironmentState.Initializing,
+ WorkspaceInstanceStatus.Phase.PHASE_PENDING to StandardRemoteEnvironmentState.Initializing,
+ WorkspaceInstanceStatus.Phase.PHASE_CREATING to StandardRemoteEnvironmentState.Initializing,
+ WorkspaceInstanceStatus.Phase.PHASE_INITIALIZING to StandardRemoteEnvironmentState.Initializing,
+ WorkspaceInstanceStatus.Phase.PHASE_RUNNING to StandardRemoteEnvironmentState.Active,
+ WorkspaceInstanceStatus.Phase.PHASE_INTERRUPTED to StandardRemoteEnvironmentState.Error,
+ WorkspaceInstanceStatus.Phase.PHASE_STOPPING to StandardRemoteEnvironmentState.Unreachable,
+ WorkspaceInstanceStatus.Phase.PHASE_STOPPED to StandardRemoteEnvironmentState.Hibernated,
+ )
+ }
+}
\ No newline at end of file
diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodSSHEnvironmentContentsView.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodSSHEnvironmentContentsView.kt
new file mode 100644
index 00000000000000..08178004796b3b
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodSSHEnvironmentContentsView.kt
@@ -0,0 +1,46 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+package io.gitpod.toolbox.gateway
+
+import com.jetbrains.toolbox.gateway.environments.ManualEnvironmentContentsView
+import com.jetbrains.toolbox.gateway.environments.SshEnvironmentContentsView
+import com.jetbrains.toolbox.gateway.ssh.SshConnectionInfo
+import io.gitpod.toolbox.auth.GitpodAuthManager
+import io.gitpod.toolbox.service.ConnectParams
+import io.gitpod.toolbox.service.GitpodConnectionProvider
+import io.gitpod.toolbox.service.GitpodPublicApiManager
+import io.gitpod.toolbox.service.Utils
+import kotlinx.coroutines.future.future
+import java.util.concurrent.CompletableFuture
+
+class GitpodSSHEnvironmentContentsView(
+ private val authManager: GitpodAuthManager,
+ private val connectParams: ConnectParams,
+ private val publicApi: GitpodPublicApiManager,
+) : SshEnvironmentContentsView, ManualEnvironmentContentsView {
+ private var cancel = {}
+ private val stateListeners = mutableSetOf()
+
+ override fun getConnectionInfo(): CompletableFuture {
+ return Utils.coroutineScope.future {
+ val provider = GitpodConnectionProvider(authManager, connectParams, publicApi)
+ val (connInfo, cancel) = provider.connect()
+ this@GitpodSSHEnvironmentContentsView.cancel = cancel
+ return@future connInfo
+ }
+ }
+
+ override fun addEnvironmentContentsListener(p0: ManualEnvironmentContentsView.Listener) {
+ stateListeners += p0
+ }
+
+ override fun removeEnvironmentContentsListener(p0: ManualEnvironmentContentsView.Listener) {
+ stateListeners -= p0
+ }
+
+ override fun close() {
+ cancel()
+ }
+}
diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodSettings.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodSettings.kt
new file mode 100644
index 00000000000000..7c80083868da7b
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodSettings.kt
@@ -0,0 +1,38 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+package io.gitpod.toolbox.gateway
+
+import io.gitpod.toolbox.service.Utils
+import io.gitpod.toolbox.utils.GitpodLogger
+
+class GitpodSettings {
+ private val settingsChangedListeners: MutableList<(String, String) -> Unit> = mutableListOf()
+
+ private fun getStoreKey(key: SettingKey) = "GITPOD_SETTINGS:${key.name}"
+
+ private fun updateSetting(key: SettingKey, value: String) {
+ GitpodLogger.debug("updateSetting ${key.name}=$value")
+ Utils.settingStore[getStoreKey(key)] = value
+ settingsChangedListeners.forEach { it(key.name, value) }
+ }
+
+ fun onSettingsChanged(listener: (String, String) -> Unit) {
+ settingsChangedListeners.add(listener)
+ }
+
+ fun resetSettings(host: String = "gitpod.io") {
+ gitpodHost = host
+ }
+
+ var gitpodHost: String
+ get() = Utils.settingStore[getStoreKey(SettingKey.GITPOD_HOST)] ?: "gitpod.io"
+ set(value) {
+ updateSetting(SettingKey.GITPOD_HOST, value)
+ }
+
+ enum class SettingKey {
+ GITPOD_HOST
+ }
+}
diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodUriHandler.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodUriHandler.kt
new file mode 100644
index 00000000000000..a9aa7fcfab6be7
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodUriHandler.kt
@@ -0,0 +1,62 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+package io.gitpod.toolbox.gateway
+
+import io.gitpod.toolbox.service.ConnectParams
+import io.gitpod.toolbox.utils.GitpodLogger
+import java.net.URI
+import java.util.concurrent.Future
+
+interface UriHandler {
+ fun parseUri(uri: URI): T
+ fun handle(data: T): Future
+ fun tryHandle(uri: URI): Boolean
+}
+
+abstract class AbstractUriHandler : UriHandler {
+ abstract override fun parseUri(uri: URI): T
+ abstract override fun handle(data: T): Future
+
+ override fun tryHandle(uri: URI) = try {
+ val data = parseUri(uri)
+ handle(data)
+ true
+ } catch (e: Exception) {
+ GitpodLogger.warn("cannot parse URI", e)
+ false
+ }
+}
+
+class GitpodOpenInToolboxUriHandler(val handler: (Pair) -> Future) : AbstractUriHandler>() {
+
+ override fun handle(data: Pair): Future {
+ return handler(data)
+ }
+
+ override fun parseUri(uri: URI): Pair {
+ val path = uri.path.split("/").last()
+ if (path != "open-in-toolbox") {
+ throw IllegalArgumentException("invalid URI: $path")
+ }
+ val query = uri.query ?: throw IllegalArgumentException("invalid URI: ${uri.query}")
+ val params = query.split("&").map { it.split("=") }.associate { it[0] to it[1] }
+ val host = params["host"]
+ val workspaceId = params["workspaceId"]
+ val debugWorkspace = params["debugWorkspace"]?.toBoolean() ?: false
+
+ if (host.isNullOrEmpty() || workspaceId.isNullOrEmpty()) {
+ throw IllegalArgumentException("invalid URI: host or workspaceId is missing: $uri")
+ }
+
+ try {
+ URI.create(host)
+ } catch (e: IllegalArgumentException) {
+ throw IllegalArgumentException("invalid host: $host")
+ }
+ GitpodLogger.debug("parsed URI: $host, $workspaceId, $debugWorkspace")
+ val gitpodHost = "https://$host"
+ return Pair(gitpodHost, ConnectParams(workspaceId, gitpodHost, debugWorkspace))
+ }
+}
diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/DataManager.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/DataManager.kt
new file mode 100644
index 00000000000000..6e0161cb29d670
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/DataManager.kt
@@ -0,0 +1,45 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+package io.gitpod.toolbox.service
+
+import io.gitpod.publicapi.experimental.v1.Workspaces.Workspace
+import io.gitpod.toolbox.utils.await
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import okhttp3.Request
+import java.net.URL
+
+fun Workspace.getConnectParams(): ConnectParams {
+ return ConnectParams(workspaceId, getGitpodHost(), false)
+}
+
+fun Workspace.getIDEUrl(): String {
+ return status.instance.status.url
+}
+
+fun Workspace.getGitpodHost(): String {
+ val ideUrl = URL(getIDEUrl())
+ val hostSegments = ideUrl.host.split(".")
+ return hostSegments.takeLast(2).joinToString(".")
+}
+
+@Serializable
+class JoinLink2Response(val appPid: Int, val joinLink: String, val ideVersion: String, val projectPath: String)
+
+suspend fun Workspace.fetchJoinLink2Info(ownerToken: String): JoinLink2Response {
+ val backendUrl = "https://24000-${URL(getIDEUrl()).host}/joinLink2"
+ val client = Utils.httpClient
+ val req = Request.Builder().url(backendUrl).header("x-gitpod-owner-token", ownerToken)
+ val response = client.newCall(req.build()).await()
+ if (!response.isSuccessful) {
+ throw IllegalStateException("Failed to get join link $backendUrl info: ${response.code} ${response.message}")
+ }
+ if (response.body == null) {
+ throw IllegalStateException("Failed to get join link $backendUrl info: no body")
+ }
+ return Json.decodeFromString(response.body!!.string())
+}
+
diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/GitpodConnectionProvider.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/GitpodConnectionProvider.kt
new file mode 100644
index 00000000000000..094e858b4633af
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/GitpodConnectionProvider.kt
@@ -0,0 +1,94 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+package io.gitpod.toolbox.service
+
+import com.jetbrains.rd.util.ConcurrentHashMap
+import com.jetbrains.rd.util.URI
+import com.jetbrains.toolbox.gateway.ssh.SshConnectionInfo
+import io.gitpod.publicapi.experimental.v1.Workspaces
+import io.gitpod.toolbox.auth.GitpodAuthManager
+import io.gitpod.toolbox.utils.GitpodLogger
+import kotlinx.serialization.Serializable
+
+class GitpodConnectionProvider(
+ private val authManager: GitpodAuthManager,
+ private val connectParams: ConnectParams,
+ private val publicApi: GitpodPublicApiManager,
+) {
+ private val activeConnections = ConcurrentHashMap()
+
+ suspend fun connect(): Pair Unit> {
+ val workspaceId = connectParams.workspaceId
+ val workspace = publicApi.getWorkspace(workspaceId)
+ val ownerToken = publicApi.getWorkspaceOwnerToken(workspaceId)
+
+ val (serverPort, cancel) = tunnelWithWebSocket(workspace, connectParams, ownerToken)
+
+ val connInfo = GitpodWebSocketSshConnectionInfo(
+ "gitpod",
+ "localhost",
+ serverPort,
+ )
+ return (connInfo to cancel)
+ }
+
+ private fun tunnelWithWebSocket(
+ workspace: Workspaces.Workspace,
+ connectParams: ConnectParams,
+ ownerToken: String,
+ ): Pair Unit> {
+ val connectionKeyId = connectParams.uniqueID
+
+ var found = true
+ activeConnections.computeIfAbsent(connectionKeyId) {
+ found = false
+ true
+ }
+
+ if (found) {
+ val errMessage = "A connection to the same workspace already exists: $connectionKeyId"
+ throw IllegalStateException(errMessage)
+ }
+
+ val workspaceHost = URI.create(workspace.status.instance.status.url).host
+ val server =
+ GitpodWebSocketTunnelServer("wss://${workspaceHost}/_supervisor/tunnel/ssh", ownerToken)
+
+ val cancelServer = server.start()
+
+ return (server.port to {
+ activeConnections.remove(connectionKeyId)
+ cancelServer()
+ })
+ }
+}
+
+class GitpodWebSocketSshConnectionInfo(
+ private val username: String,
+ private val host: String,
+ private val port: Int,
+) : SshConnectionInfo {
+ override fun getHost() = host
+ override fun getPort() = port
+ override fun getUserName() = username
+ override fun getShouldAskForPassword() = false
+ override fun getShouldUseSystemSshAgent() = true
+}
+
+data class ConnectParams(
+ val workspaceId: String,
+ val host: String,
+ val debugWorkspace: Boolean = false,
+) {
+ val resolvedWorkspaceId = "${if (debugWorkspace) "debug-" else ""}$workspaceId"
+ val title = "$resolvedWorkspaceId"
+ val uniqueID = "$workspaceId-$debugWorkspace"
+}
+
+@Serializable
+private data class SSHPublicKey(
+ val type: String,
+ val value: String
+)
diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/GitpodPublicApiManager.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/GitpodPublicApiManager.kt
new file mode 100644
index 00000000000000..cb44d138147a41
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/GitpodPublicApiManager.kt
@@ -0,0 +1,148 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+package io.gitpod.toolbox.service
+
+import com.connectrpc.*
+import com.connectrpc.extensions.GoogleJavaProtobufStrategy
+import com.connectrpc.http.clone
+import com.connectrpc.impl.ProtocolClient
+import com.connectrpc.okhttp.ConnectOkHttpClient
+import com.connectrpc.protocols.NetworkProtocol
+import io.gitpod.publicapi.experimental.v1.*
+import io.gitpod.toolbox.auth.GitpodAccount
+import io.gitpod.toolbox.auth.GitpodAuthManager
+import io.gitpod.toolbox.utils.GitpodLogger
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import java.time.Duration
+
+class GitpodPublicApiManager(private val authManger: GitpodAuthManager) {
+ private var workspaceApi: WorkspacesServiceClientInterface? = null
+ private var organizationApi: TeamsServiceClientInterface? = null
+ private var userApi: UserServiceClientInterface? = null
+ private var account: GitpodAccount? = null
+
+ init {
+ authManger.addLogoutListener {
+ workspaceApi = null
+ organizationApi = null
+ userApi = null
+ account = null
+ }
+ }
+
+ fun setup() {
+ val account = authManger.getCurrentAccount() ?: return
+ this.account = account
+ GitpodLogger.info("setup papi client for ${account.getHost()}")
+ val client = createClient(account.getHost(), account.getCredentials())
+ workspaceApi = WorkspacesServiceClient(client)
+ organizationApi = TeamsServiceClient(client)
+ userApi = UserServiceClient(client)
+ }
+
+ fun watchWorkspaceStatus(workspaceId: String, consumer: (String, Workspaces.WorkspaceInstanceStatus) -> Unit): Job {
+ val workspaceApi = workspaceApi ?: throw IllegalStateException("No client")
+
+ return Utils.coroutineScope.launch {
+ val workspace = getWorkspace(workspaceId)
+ consumer(workspace.workspaceId, workspace.status.instance.status)
+ val stream = workspaceApi.streamWorkspaceStatus()
+ stream.sendAndClose(Workspaces.StreamWorkspaceStatusRequest.newBuilder().setWorkspaceId(workspaceId).build())
+ val chan = stream.responseChannel()
+ try {
+ for (response in chan) {
+ consumer(response.result.instance.workspaceId, response.result.instance.status)
+ }
+ }
+ finally {
+ chan.cancel()
+ }
+ }
+ }
+
+ suspend fun listWorkspaces(): List {
+ val workspaceApi = workspaceApi ?: throw IllegalStateException("No client")
+ val resp = workspaceApi.listWorkspaces(Workspaces.ListWorkspacesRequest.newBuilder().build())
+ return this.handleResp("listWorkspaces", resp).resultList
+ }
+
+ suspend fun getWorkspace(workspaceId: String): Workspaces.Workspace {
+ val workspaceApi = workspaceApi ?: throw IllegalStateException("No client")
+ val resp = workspaceApi.getWorkspace(Workspaces.GetWorkspaceRequest.newBuilder().setWorkspaceId(workspaceId).build())
+ return this.handleResp("getWorkspace", resp).result
+ }
+
+ suspend fun getWorkspaceOwnerToken(workspaceId: String): String {
+ val workspaceApi = workspaceApi ?: throw IllegalStateException("No client")
+ val resp = workspaceApi.getOwnerToken(Workspaces.GetOwnerTokenRequest.newBuilder().setWorkspaceId(workspaceId).build())
+ return this.handleResp("getOwnerToken", resp).token
+ }
+
+ suspend fun getAuthenticatedUser(): UserOuterClass.User {
+ return tryGetAuthenticatedUser(userApi)
+ }
+
+ private fun handleResp(method: String, resp: ResponseMessage): T {
+ val data = resp.success { it.message }
+ val error = resp.failure {
+ GitpodLogger.error("failed to call papi.${method} $it")
+ it.cause
+ }
+ return data ?: throw error!!
+ }
+
+ companion object {
+ fun createClient(gitpodHost: String, token: String): ProtocolClient {
+ // TODO: 6m?
+ val client = Utils.httpClient.newBuilder().readTimeout(Duration.ofMinutes(6)).build()
+ val authInterceptor = AuthorizationInterceptor(token)
+ return ProtocolClient(
+ httpClient = ConnectOkHttpClient(client),
+ ProtocolClientConfig(
+ host = "https://api.$gitpodHost",
+ serializationStrategy = GoogleJavaProtobufStrategy(), // Or GoogleJavaJSONStrategy for JSON.
+ networkProtocol = NetworkProtocol.CONNECT,
+ interceptors = listOf { authInterceptor }
+ ),
+ )
+ }
+
+ /**
+ * Tries to get the authenticated user from the given API client.
+ * Used in GitpodAuthManager
+ */
+ suspend fun tryGetAuthenticatedUser(api: UserServiceClientInterface?): UserOuterClass.User {
+ val userApi = api ?: throw IllegalStateException("No client")
+ val resp = userApi.getAuthenticatedUser(UserOuterClass.GetAuthenticatedUserRequest.newBuilder().build())
+ val user = resp.success { it.message.user }
+ val err = resp.failure {
+ GitpodLogger.error("failed to call papi.getAuthenticatedUser $it")
+ it.cause
+ }
+ return user ?: throw err!!
+ }
+ }
+}
+
+class AuthorizationInterceptor(private val token: String) : Interceptor {
+ override fun streamFunction() = StreamFunction({
+ val headers = mutableMapOf>()
+ headers.putAll(it.headers)
+ headers["Authorization"] = listOf("Bearer $token")
+ return@StreamFunction it.clone(headers = headers)
+ })
+
+ override fun unaryFunction() = UnaryFunction(
+ {
+ val headers = mutableMapOf>()
+ headers.putAll(it.headers)
+ headers["Authorization"] = listOf("Bearer $token")
+ return@UnaryFunction it.clone(headers = headers)
+ },
+ )
+}
+
+// TODO: logger interceptor
diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/GitpodWebSocketTunnelServer.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/GitpodWebSocketTunnelServer.kt
new file mode 100644
index 00000000000000..36cd98da6fae8e
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/GitpodWebSocketTunnelServer.kt
@@ -0,0 +1,208 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+package io.gitpod.toolbox.service
+
+import io.gitpod.toolbox.utils.GitpodLogger
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import org.eclipse.jetty.client.HttpClient
+import org.eclipse.jetty.client.HttpProxy
+import org.eclipse.jetty.client.Socks4Proxy
+import org.eclipse.jetty.util.ssl.SslContextFactory
+import org.eclipse.jetty.websocket.jsr356.ClientContainer
+import java.net.*
+import java.nio.ByteBuffer
+import java.util.*
+import java.util.concurrent.CopyOnWriteArrayList
+import javax.net.ssl.SSLContext
+import javax.websocket.*
+import javax.websocket.ClientEndpointConfig.Configurator
+import javax.websocket.MessageHandler.Partial
+
+class GitpodWebSocketTunnelServer(
+ private val url: String,
+ private val ownerToken: String,
+) {
+ val port: Int get() = serverSocket.localPort
+ private val serverSocket = ServerSocket(0) // pass 0 to have the system choose a free port
+ private val logPrefix = "tunnel: [$url]:"
+ private val clients = CopyOnWriteArrayList()
+
+ fun start(): () -> Unit {
+ val job = Utils.coroutineScope.launch(Dispatchers.IO) {
+ GitpodLogger.info("tunnel: [$url]: listening on port $port")
+ try {
+ while (isActive) {
+ try {
+ val clientSocket = serverSocket.accept()
+ launch(Dispatchers.IO) {
+ handleClientConnection(clientSocket)
+ }
+ } catch (t: Throwable) {
+ if (isActive) {
+ GitpodLogger.error("$logPrefix failed to accept", t)
+ }
+ }
+ }
+ } catch (t: Throwable) {
+ if (isActive) {
+ GitpodLogger.error("$logPrefix failed to listen", t)
+ }
+ } finally {
+ GitpodLogger.info("$logPrefix stopped")
+ }
+ }
+ return {
+ job.cancel()
+ serverSocket.close()
+ clients.forEach { it.close() }
+ clients.clear()
+ }
+ }
+
+ private fun handleClientConnection(clientSocket: Socket) {
+ val socketClient = GitpodWebSocketTunnelClient(url, clientSocket)
+ try {
+ val inputStream = clientSocket.getInputStream()
+ val outputStream = clientSocket.getOutputStream()
+
+ // Forward data from WebSocket to TCP client
+ socketClient.onMessageCallback = { data ->
+ outputStream.write(data)
+ GitpodLogger.trace("$logPrefix received ${data.size} bytes")
+ }
+
+ connectToWebSocket(socketClient)
+
+ clients.add(socketClient)
+
+ val buffer = ByteArray(1024)
+ var read: Int
+ while (inputStream.read(buffer).also { read = it } != -1) {
+ // Forward data from TCP to WebSocket
+ socketClient.sendData(buffer.copyOfRange(0, read))
+ GitpodLogger.trace("$logPrefix sent $read bytes")
+ }
+ } catch (t: Throwable) {
+ if (t is SocketException && t.message?.contains("Socket closed") == true) {
+ return
+ }
+ GitpodLogger.error("$logPrefix failed to pipe", t)
+ } finally {
+ clients.remove(socketClient)
+ socketClient.close()
+ }
+ }
+
+ private fun connectToWebSocket(socketClient: GitpodWebSocketTunnelClient) {
+ val ssl: SslContextFactory = SslContextFactory.Client()
+ ssl.sslContext = SSLContext.getDefault()
+ val httpClient = HttpClient(ssl)
+ val proxies = Utils.getProxyList()
+ for (proxy in proxies) {
+ if (proxy.type() == Proxy.Type.DIRECT) {
+ continue
+ }
+ val proxyAddress = proxy.address()
+ if (proxyAddress !is InetSocketAddress) {
+ GitpodLogger.warn("$logPrefix unexpected proxy: $proxy")
+ continue
+ }
+ val hostName = proxyAddress.hostString
+ val port = proxyAddress.port
+ if (proxy.type() == Proxy.Type.HTTP) {
+ httpClient.proxyConfiguration.proxies.add(HttpProxy(hostName, port))
+ } else if (proxy.type() == Proxy.Type.SOCKS) {
+ httpClient.proxyConfiguration.proxies.add(Socks4Proxy(hostName, port))
+ }
+ }
+ val container = ClientContainer(httpClient)
+
+ // stop container immediately since we close only when a session is already gone
+ container.stopTimeout = 0
+
+ // allow clientContainer to own httpClient (for start/stop lifecycle)
+ container.client.addManaged(httpClient)
+ container.start()
+
+ // Create config to add custom headers
+ val config = ClientEndpointConfig.Builder.create()
+ .configurator(object : Configurator() {
+ override fun beforeRequest(headers: MutableMap>) {
+ headers["x-gitpod-owner-token"] = Collections.singletonList(ownerToken)
+ headers["user-agent"] = Collections.singletonList("gitpod-toolbox")
+ }
+ })
+ .build()
+
+ try {
+ socketClient.container = container;
+ container.connectToServer(socketClient, config, URI(url))
+ } catch (t: Throwable) {
+ container.stop()
+ throw t
+ }
+ }
+
+}
+
+class GitpodWebSocketTunnelClient(url: String, private val tcpSocket: Socket) : Endpoint(), Partial {
+ private lateinit var webSocketSession: Session
+ var onMessageCallback: ((ByteArray) -> Unit)? = null
+ var container: ClientContainer? = null
+ private val logPrefix = "tunnel: [$url]:"
+
+ override fun onOpen(session: Session, config: EndpointConfig) {
+ session.addMessageHandler(this)
+ this.webSocketSession = session
+ }
+
+ override fun onClose(session: Session, closeReason: CloseReason) {
+ GitpodLogger.info("$logPrefix closed ($closeReason)")
+ this.doClose()
+ }
+
+ override fun onError(session: Session?, thr: Throwable?) {
+ GitpodLogger.error("$logPrefix failed", thr)
+ this.doClose()
+ }
+
+ private fun doClose() {
+ try {
+ tcpSocket.close()
+ } catch (t: Throwable) {
+ GitpodLogger.error("$logPrefix failed to close socket", t)
+ }
+ try {
+ container?.stop()
+ } catch (t: Throwable) {
+ GitpodLogger.error("$logPrefix failed to stop container", t)
+ }
+ }
+
+ fun sendData(data: ByteArray) {
+ webSocketSession.asyncRemote.sendBinary(ByteBuffer.wrap(data))
+ }
+
+ fun close() {
+ try {
+ webSocketSession.close()
+ } catch (t: Throwable) {
+ GitpodLogger.error("$logPrefix failed to close", t)
+ }
+ try {
+ container?.stop()
+ } catch (t: Throwable) {
+ GitpodLogger.error("$logPrefix failed to stop container", t)
+ }
+ }
+
+ override fun onMessage(partialMessage: ByteBuffer, last: Boolean) {
+ val data = ByteArray(partialMessage.remaining())
+ partialMessage.get(data)
+ onMessageCallback?.invoke(data)
+ }
+}
diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/Utils.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/Utils.kt
new file mode 100644
index 00000000000000..bf3d9d60ff61cc
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/Utils.kt
@@ -0,0 +1,64 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+package io.gitpod.toolbox.service
+
+import com.jetbrains.toolbox.gateway.PluginSettingsStore
+import com.jetbrains.toolbox.gateway.ToolboxServiceLocator
+import com.jetbrains.toolbox.gateway.connection.ClientHelper
+import com.jetbrains.toolbox.gateway.connection.ToolboxProxySettings
+import com.jetbrains.toolbox.gateway.ssh.validation.SshConnectionValidator
+import com.jetbrains.toolbox.gateway.ui.ObservablePropertiesFactory
+import com.jetbrains.toolbox.gateway.ui.ToolboxUi
+import io.gitpod.toolbox.gateway.GitpodSettings
+import kotlinx.coroutines.CoroutineScope
+import okhttp3.OkHttpClient
+import java.net.Proxy
+import java.util.concurrent.atomic.AtomicBoolean
+
+object Utils {
+ lateinit var sharedServiceLocator: ToolboxServiceLocator private set
+ lateinit var coroutineScope: CoroutineScope private set
+ lateinit var settingStore: PluginSettingsStore private set
+ lateinit var sshConnectionValidator: SshConnectionValidator private set
+ lateinit var httpClient: OkHttpClient private set
+ lateinit var clientHelper: ClientHelper private set
+ lateinit var observablePropertiesFactory: ObservablePropertiesFactory private set
+ lateinit var proxySettings: ToolboxProxySettings private set
+
+ lateinit var gitpodSettings: GitpodSettings private set
+
+ lateinit var toolboxUi: ToolboxUi private set
+
+
+ fun initialize(serviceLocator: ToolboxServiceLocator) {
+ if (!isInitialized.compareAndSet(false, true)) {
+ return
+ }
+ sharedServiceLocator = serviceLocator
+ coroutineScope = serviceLocator.getService(CoroutineScope::class.java)
+ toolboxUi = serviceLocator.getService(ToolboxUi::class.java)
+ settingStore = serviceLocator.getService(PluginSettingsStore::class.java)
+ sshConnectionValidator = serviceLocator.getService(SshConnectionValidator::class.java)
+ httpClient = serviceLocator.getService(OkHttpClient::class.java)
+ clientHelper = serviceLocator.getService(ClientHelper::class.java)
+ observablePropertiesFactory = serviceLocator.getService(ObservablePropertiesFactory::class.java)
+ proxySettings = serviceLocator.getService(ToolboxProxySettings::class.java)
+ gitpodSettings = GitpodSettings()
+ }
+
+ fun openUrl(url: String) {
+ toolboxUi.openUrl(url)
+ }
+
+ fun getProxyList(): List {
+ val proxyList = mutableListOf()
+ if (proxySettings.proxy != null && proxySettings.proxy != Proxy.NO_PROXY) {
+ proxyList.add(proxySettings.proxy!!)
+ }
+ return proxyList
+ }
+
+ private val isInitialized = AtomicBoolean(false)
+}
diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/utils/GitpodLogger.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/utils/GitpodLogger.kt
new file mode 100644
index 00000000000000..fad157ef56d5cf
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/utils/GitpodLogger.kt
@@ -0,0 +1,45 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+package io.gitpod.toolbox.utils
+
+import org.slf4j.LoggerFactory
+import org.slf4j.spi.LocationAwareLogger
+
+object GitpodLogger {
+ private val logger: LocationAwareLogger = LoggerFactory.getLogger(javaClass) as LocationAwareLogger
+ private val FQCN = GitpodLogger::class.java.name
+
+ private fun formatMessage(msg: String): String {
+ return "[gitpod] $msg"
+ }
+
+ fun info(message: String) {
+ logger.log(null, FQCN, LocationAwareLogger.INFO_INT, formatMessage(message), null, null)
+ }
+
+ fun debug(message: String) {
+ logger.log(null, FQCN, LocationAwareLogger.DEBUG_INT, formatMessage(message), null, null)
+ }
+
+ fun warn(message: String) {
+ logger.log(null, FQCN, LocationAwareLogger.WARN_INT, formatMessage(message), null, null)
+ }
+
+ fun warn(message: String, throwable: Throwable?) {
+ logger.log(null, FQCN, LocationAwareLogger.WARN_INT, formatMessage(message), null, throwable)
+ }
+
+ fun error(message: String) {
+ logger.log(null, FQCN, LocationAwareLogger.ERROR_INT, formatMessage(message), null, null)
+ }
+
+ fun error(message: String, throwable: Throwable?) {
+ logger.log(null, FQCN, LocationAwareLogger.ERROR_INT, formatMessage(message), null, throwable)
+ }
+
+ fun trace(message: String) {
+ logger.log(null, FQCN, LocationAwareLogger.TRACE_INT, formatMessage(message), null, null)
+ }
+}
diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/utils/await.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/utils/await.kt
new file mode 100644
index 00000000000000..a5d7e65a700c6d
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/utils/await.kt
@@ -0,0 +1,30 @@
+// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
+// Licensed under the GNU Affero General Public License (AGPL).
+// See License.AGPL.txt in the project root for license information.
+
+package io.gitpod.toolbox.utils
+
+import kotlinx.coroutines.suspendCancellableCoroutine
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.Response
+import java.io.IOException
+
+suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation ->
+ enqueue(object : Callback {
+ override fun onResponse(call: Call, response: Response) {
+ continuation.resumeWith(Result.success(response))
+ }
+
+ override fun onFailure(call: Call, e: IOException) {
+ if (continuation.isCancelled) return
+ continuation.resumeWith(Result.failure(e))
+ }
+ })
+ continuation.invokeOnCancellation {
+ try {
+ cancel()
+ } catch (_: Exception) {
+ }
+ }
+}
diff --git a/components/ide/jetbrains/toolbox/src/main/resources/META-INF/services/com.jetbrains.toolbox.gateway.GatewayExtension b/components/ide/jetbrains/toolbox/src/main/resources/META-INF/services/com.jetbrains.toolbox.gateway.GatewayExtension
new file mode 100644
index 00000000000000..b225999a57740a
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/resources/META-INF/services/com.jetbrains.toolbox.gateway.GatewayExtension
@@ -0,0 +1 @@
+io.gitpod.toolbox.gateway.GitpodGatewayExtension
diff --git a/components/ide/jetbrains/toolbox/src/main/resources/dependencies.json b/components/ide/jetbrains/toolbox/src/main/resources/dependencies.json
new file mode 100644
index 00000000000000..01b3cbeb86ebf6
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/resources/dependencies.json
@@ -0,0 +1,44 @@
+[
+ {
+ "name": "Toolbox App plugin API",
+ "version": "2.1.0.16946",
+ "url": "https://jetbrains.com/toolbox-app/",
+ "license": "The Apache Software License, Version 2.0",
+ "licenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
+ },
+ {
+ "name": "com.squareup.okhttp3:okhttp",
+ "version": "4.10.0",
+ "url": "https://square.github.io/okhttp/",
+ "license": "The Apache Software License, Version 2.0",
+ "licenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
+ },
+ {
+ "name": "Kotlin",
+ "version": "1.9.0",
+ "url": "https://kotlinlang.org/",
+ "license": "The Apache License, Version 2.0",
+ "licenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
+ },
+ {
+ "name": "kotlinx.coroutines",
+ "version": "1.7.3",
+ "url": "https://github.com/Kotlin/kotlinx.coroutines/",
+ "license": "The Apache License, Version 2.0",
+ "licenseUrl": "https://github.com/Kotlin/kotlinx.coroutines/blob/master/LICENSE.txt"
+ },
+ {
+ "name": "kotlinx.serialization",
+ "version": "1.5.0",
+ "url": "https://github.com/Kotlin/kotlinx.serialization/",
+ "license": "The Apache License, Version 2.0",
+ "licenseUrl": "https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt"
+ },
+ {
+ "name": "org.slf4j:slf4j-api",
+ "version": "2.0.3",
+ "url": "http://www.slf4j.org",
+ "license": "MIT License",
+ "licenseUrl": "http://www.opensource.org/licenses/mit-license.php"
+ }
+]
diff --git a/components/ide/jetbrains/toolbox/src/main/resources/extension.json b/components/ide/jetbrains/toolbox/src/main/resources/extension.json
new file mode 100644
index 00000000000000..67050e5a44fb97
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/resources/extension.json
@@ -0,0 +1,20 @@
+{
+ "id": "io.gitpod.toolbox.gateway",
+ "version": "0.0.1",
+ "meta": {
+ "readableName": "Gitpod plugin",
+ "description": "Gitpod CDE(Cloud Development Environment) integration into JetBrains Toolbox App",
+ "vendor": "Toolbox + Gateway",
+ "url": "https://github.com/gitpod-io/gitpod",
+ "backgroundColors": {
+ "start": { "hex": "#FFB45B", "opacity": 0.7 },
+ "top": { "hex": "#FFB45B", "opacity": 0.6 },
+ "end": { "hex": "#FFB45B", "opacity": 0.8 }
+ }
+ },
+ "apiVersion": "0.1.0",
+ "compatibleVersionRange": {
+ "from": "2.1.0",
+ "to": "2.6.0"
+ }
+}
diff --git a/components/ide/jetbrains/toolbox/src/main/resources/icon.svg b/components/ide/jetbrains/toolbox/src/main/resources/icon.svg
new file mode 100644
index 00000000000000..788431d80e068f
--- /dev/null
+++ b/components/ide/jetbrains/toolbox/src/main/resources/icon.svg
@@ -0,0 +1 @@
+