Skip to content

Commit 8dfb5fb

Browse files
authored
Merge pull request #111 from ServiceNow/new-dispatcher
Managed Instance Dispatcher
2 parents 225a419 + 1ebb0be commit 8dfb5fb

File tree

9 files changed

+192
-57
lines changed

9 files changed

+192
-57
lines changed

README.md

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -41,40 +41,26 @@ https://github.com/ServiceNow/WorkArena/assets/2374980/68640f09-7d6f-4eb1-b556-c
4141

4242
## Getting Started
4343

44-
To setup WorkArena, you will need to get your own ServiceNow instance, install our Python package, and upload some data to your instance. Follow the steps below to achieve this.
44+
To setup WorkArena, you will need to gain access to ServiceNow instances and install our Python package locally. Follow the steps below to achieve this.
4545

46-
### a) Create a ServiceNow Developer Instance
46+
### a) Gain Access to ServiceNow Instances
4747

48-
1. Go to https://developer.servicenow.com/ and create an account.
49-
2. Click on `Request an instance` and select the `Washington` release (initializing the instance will take a few minutes)
50-
* **Important note:** ServiceNow no longer provides Washington instances to the public. Please fill out [this form](forms.gle/6WLf6s9nkA5D8aue6) to request one.
51-
4. Once the instance is ready, you should see your instance URL and credentials. If not, click _Return to the Developer Portal_, then navigate to _Manage instance password_ and click _Reset instance password_.
52-
5. Change the role of the user to admin in yoyr instance parameters ![image](https://github.com/user-attachments/assets/6f0fbf8e-f40f-411a-84cb-fead93d85f60)
48+
1. Navigate to https://huggingface.co/datasets/ServiceNow/WorkArena-Instances.
49+
2. Fill the form, accept the terms to gain access to the gated repository and wait for approval.
50+
3. Ensure that the machine where you will run WorkArena is [authenticated with Hugging Face](https://huggingface.co/docs/hub/en/datasets-polars-auth) (e.g., via huggingface-cli login or the HUGGING_FACE_HUB_TOKEN environment variable).
5351

54-
6. You should now see your URL and credentials. Based on this information, set the following environment variables:
55-
* `SNOW_INSTANCE_URL`: The URL of your ServiceNow developer instance
56-
* `SNOW_INSTANCE_UNAME`: The username, should be "admin"
57-
* `SNOW_INSTANCE_PWD`: The password, make sure you place the value in quotes "" and be mindful of [escaping special shell characters](https://onlinelinuxtools.com/escape-shell-characters). Running `echo $SNOW_INSTANCE_PWD` should print the correct password.
58-
7. Log into your instance via a browser using the admin credentials. Close any popup that appears on the main screen (e.g., agreeing to analytics).
59-
60-
**Warning:** Feel free to look around the platform, but please make sure you revert any changes (e.g., changes to list views, pinning some menus, etc.) as these changes will be persistent and affect the benchmarking process.
61-
62-
### b) Install WorkArena and Initialize your Instance
52+
### b) Install WorkArena
6353

6454
Run the following command to install WorkArena in the [BrowswerGym](https://github.com/servicenow/browsergym) environment:
6555
```
66-
pip install browsergym
56+
pip install browsergym-workarena
6757
```
6858

6959
Then, install [Playwright](https://github.com/microsoft/playwright):
7060
```
7161
playwright install
7262
```
7363

74-
Finally, run this command in a terminal to upload the benchmark data to your ServiceNow instance:
75-
```
76-
workarena-install
77-
```
7864
Your installation is now complete! 🎉
7965

8066

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ numpy>=1.14
55
requests>=2.31
66
tenacity>=8.2.3 # only used in cheat() -> move to tests?
77
tqdm>=4.66.2
8+
huggingface_hub>=0.23

src/browsergym/workarena/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111
SNOW_JS_UTILS_FILEPATH = str(resources.files(utils).joinpath("js_utils.js"))
1212
SNOW_SUPPORTED_RELEASES = ["washingtondc"]
1313

14+
# Hugging Face dataset containing available instances
15+
INSTANCE_REPO_ID = "ServiceNow/WorkArena-Instances"
16+
INSTANCE_REPO_FILENAME = "instances.json"
17+
INSTANCE_REPO_TYPE = "dataset"
18+
INSTANCE_XOR_SEED = "x3!+-9mi#nhlo%a02$9hna{]"
19+
1420
# Path to the Menu navigation task configuration
1521
ALL_MENU_PATH = str(resources.files(data_files).joinpath("task_configs/all_menu.json"))
1622

src/browsergym/workarena/install.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from __future__ import annotations
2+
3+
import argparse
14
import html
25
import json
36
import logging
@@ -48,10 +51,26 @@
4851
UI_THEMES_UPDATE_SET,
4952
)
5053
from .api.user import set_user_preference
51-
from .instance import SNowInstance
54+
from .instance import SNowInstance as _BaseSNowInstance
5255
from .utils import url_login
5356

5457

58+
_CLI_INSTANCE_URL: str | None = None
59+
_CLI_INSTANCE_PASSWORD: str | None = None
60+
61+
62+
def SNowInstance():
63+
"""
64+
Wrapper around the standard SNowInstance that always uses CLI-provided credentials.
65+
"""
66+
if not _CLI_INSTANCE_URL or not _CLI_INSTANCE_PASSWORD:
67+
raise RuntimeError("Installer requires --instance-url and --instance-password arguments.")
68+
return _BaseSNowInstance(
69+
snow_url=_CLI_INSTANCE_URL,
70+
snow_credentials=("admin", _CLI_INSTANCE_PASSWORD),
71+
)
72+
73+
5574
def _is_dev_portal_instance() -> bool:
5675
"""
5776
Check if the instance is a ServiceNow Developer Portal instance.
@@ -1105,6 +1124,23 @@ def main():
11051124
Entrypoint for package CLI installation command
11061125
11071126
"""
1127+
parser = argparse.ArgumentParser(
1128+
description="Install WorkArena artifacts on a ServiceNow instance."
1129+
)
1130+
parser.add_argument(
1131+
"--instance-url", required=True, help="URL of the target ServiceNow instance."
1132+
)
1133+
parser.add_argument(
1134+
"--instance-password",
1135+
required=True,
1136+
help="Password for the admin user on the target ServiceNow instance.",
1137+
)
1138+
args = parser.parse_args()
1139+
1140+
global _CLI_INSTANCE_URL, _CLI_INSTANCE_PASSWORD
1141+
_CLI_INSTANCE_URL = args.instance_url
1142+
_CLI_INSTANCE_PASSWORD = args.instance_password
1143+
11081144
logging.basicConfig(level=logging.INFO)
11091145

11101146
try:

src/browsergym/workarena/instance.py

Lines changed: 101 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,88 @@
1+
import base64
12
import json
3+
import logging
24
import os
5+
import random
36
import requests
4-
import re
7+
from itertools import cycle
58

9+
from huggingface_hub import hf_hub_download
10+
from huggingface_hub.utils import disable_progress_bars
611
from playwright.sync_api import sync_playwright
712
from typing import Optional
813

9-
from .config import SNOW_BROWSER_TIMEOUT, REPORT_FILTER_PROPERTY
14+
from .config import (
15+
INSTANCE_REPO_FILENAME,
16+
INSTANCE_REPO_ID,
17+
INSTANCE_REPO_TYPE,
18+
INSTANCE_XOR_SEED,
19+
REPORT_FILTER_PROPERTY,
20+
SNOW_BROWSER_TIMEOUT,
21+
)
22+
23+
24+
# Required to read the instance credentials
25+
if not INSTANCE_XOR_SEED:
26+
raise ValueError("INSTANCE_XOR_SEED must be configured")
27+
28+
29+
def _xor_cipher(data: bytes, key: bytes) -> bytes:
30+
return bytes(b ^ k for b, k in zip(data, cycle(key)))
31+
32+
33+
def decrypt_instance_password(encrypted_password: str) -> str:
34+
"""Decrypt a base64-encoded XOR-obfuscated password using the shared key."""
35+
36+
cipher_bytes = base64.b64decode(encrypted_password)
37+
plain_bytes = _xor_cipher(cipher_bytes, INSTANCE_XOR_SEED.encode("utf-8"))
38+
return plain_bytes.decode("utf-8")
39+
40+
41+
def encrypt_instance_password(password: str) -> str:
42+
"""Helper to produce encrypted passwords for populating the instance file."""
43+
44+
cipher_bytes = _xor_cipher(password.encode("utf-8"), INSTANCE_XOR_SEED.encode("utf-8"))
45+
return base64.b64encode(cipher_bytes).decode("utf-8")
46+
47+
48+
def fetch_instances():
49+
"""
50+
Load the latest instances from either a custom pool (SNOW_INSTANCE_POOL env var) or the gated HF dataset.
51+
"""
52+
pool_path = os.getenv("SNOW_INSTANCE_POOL")
53+
if pool_path:
54+
path = os.path.expanduser(pool_path)
55+
if not os.path.exists(path):
56+
raise FileNotFoundError(
57+
f"SNOW_INSTANCE_POOL points to '{pool_path}', but the file does not exist."
58+
)
59+
logging.info("Loading ServiceNow instances from custom pool: %s", path)
60+
else:
61+
try:
62+
disable_progress_bars()
63+
path = hf_hub_download(
64+
repo_id=INSTANCE_REPO_ID,
65+
filename=INSTANCE_REPO_FILENAME,
66+
repo_type=INSTANCE_REPO_TYPE,
67+
)
68+
logging.info("Loaded ServiceNow instances from the default instance pool.")
69+
except Exception as e:
70+
raise RuntimeError(
71+
f"Could not access {INSTANCE_REPO_ID}/{INSTANCE_REPO_FILENAME}. "
72+
"Make sure you have been granted access to the gated repo and that you are "
73+
"authenticated (run `huggingface-cli login` or set HUGGING_FACE_HUB_TOKEN)."
74+
) from e
75+
76+
with open(path, "r", encoding="utf-8") as f:
77+
entries = json.load(f)
78+
79+
for entry in entries:
80+
entry["url"] = entry["u"]
81+
entry["password"] = decrypt_instance_password(entry["p"])
82+
del entry["u"]
83+
del entry["p"]
84+
85+
return entries
1086

1187

1288
class SNowInstance:
@@ -26,31 +102,39 @@ def __init__(
26102
Parameters:
27103
-----------
28104
snow_url: str
29-
The URL of a SNow instance. If None, will try to get the value from the environment variable SNOW_INSTANCE_URL.
105+
The URL of a SNow instance. When omitted, the constructor first looks for SNOW_INSTANCE_URL and falls back
106+
to a random instance from the benchmark's instance pool if the environment variable is not set.
30107
snow_credentials: (str, str)
31-
The username and password used to access the SNow instance. If None, will try to get the values from the
32-
environment variables SNOW_INSTANCE_UNAME and SNOW_INSTANCE_PWD.
108+
The username and password used to access the SNow instance. When omitted, environment variables
109+
SNOW_INSTANCE_UNAME/SNOW_INSTANCE_PWD are used if set; otherwise, a random instance from the benchmark's
110+
instance pool is selected.
33111
34112
"""
35113
# try to get these values from environment variables if not provided
36-
if snow_url is None:
37-
if "SNOW_INSTANCE_URL" in os.environ:
114+
if snow_url is None or snow_credentials is None:
115+
116+
# Check if all required environment variables are set and if yes, fetch url and credentials from there
117+
if (
118+
"SNOW_INSTANCE_URL" in os.environ
119+
and "SNOW_INSTANCE_UNAME" in os.environ
120+
and "SNOW_INSTANCE_PWD" in os.environ
121+
):
38122
snow_url = os.environ["SNOW_INSTANCE_URL"]
39-
else:
40-
raise ValueError(
41-
f"Please provide a ServiceNow instance URL (you can use the environment variable SNOW_INSTANCE_URL)"
42-
)
43-
44-
if snow_credentials is None:
45-
if "SNOW_INSTANCE_UNAME" in os.environ and "SNOW_INSTANCE_PWD" in os.environ:
46123
snow_credentials = (
47124
os.environ["SNOW_INSTANCE_UNAME"],
48125
os.environ["SNOW_INSTANCE_PWD"],
49126
)
127+
128+
# Otherwise, load all instances and select one randomly
50129
else:
51-
raise ValueError(
52-
f"Please provide ServiceNow credentials (you can use the environment variables SNOW_INSTANCE_UNAME and SNOW_INSTANCE_PWD)"
53-
)
130+
instances = fetch_instances()
131+
if not instances:
132+
raise ValueError(
133+
f"No instances found in the dataset {INSTANCE_REPO_ID}. Please provide instance details via parameters or environment variables."
134+
)
135+
instance = random.choice(instances)
136+
snow_url = instance["url"]
137+
snow_credentials = ("admin", instance["password"])
54138

55139
# remove trailing slashes in the URL, if any
56140
self.snow_url = snow_url.rstrip("/")

src/browsergym/workarena/tasks/dashboard.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,6 @@
2929
# - We currently don't support maps because they are clickable and would require a more evolved cheat function
3030
SUPPORTED_PLOT_TYPES = ["area", "bar", "column", "line", "pie", "spline"]
3131

32-
# Get report filter config
33-
config = SNowInstance().report_filter_config
34-
if config is None:
35-
REPORT_DATE_FILTER = REPORT_TIME_FILTER = None
36-
else:
37-
REPORT_DATE_FILTER = config["report_date_filter"]
38-
REPORT_TIME_FILTER = config["report_time_filter"]
39-
del config
40-
4132

4233
class DashboardRetrievalTask(AbstractServiceNowTask, ABC):
4334
"""
@@ -303,14 +294,28 @@ def get_init_scripts(self) -> List[str]:
303294
""",
304295
]
305296

306-
def setup_goal(self, page: playwright.sync_api.Page) -> Tuple[str | dict]:
307-
super().setup_goal(page=page)
297+
def _get_filter_config(self) -> str:
298+
# Get report filter config
299+
config = self.instance.report_filter_config
300+
if config is None:
301+
REPORT_DATE_FILTER = REPORT_TIME_FILTER = None
302+
else:
303+
REPORT_DATE_FILTER = config["report_date_filter"]
304+
REPORT_TIME_FILTER = config["report_time_filter"]
305+
del config
308306

309307
# Check that the report filters are properly setup
310308
if REPORT_DATE_FILTER is None or REPORT_TIME_FILTER is None:
311309
raise RuntimeError(
312310
"The report date and time filters are not set. Please run the install script to set them."
313311
)
312+
return REPORT_DATE_FILTER, REPORT_TIME_FILTER
313+
314+
def setup_goal(self, page: playwright.sync_api.Page) -> Tuple[str | dict]:
315+
super().setup_goal(page=page)
316+
317+
# Get the instance report filter config
318+
REPORT_DATE_FILTER, REPORT_TIME_FILTER = self._get_filter_config()
314319

315320
# Configure task
316321
# ... sample a configuration
@@ -632,6 +637,9 @@ def _generate_random_config(
632637
The types of questions to sample from (uniformely)
633638
634639
"""
640+
# Get the instance report filter config
641+
REPORT_DATE_FILTER, REPORT_TIME_FILTER = self._get_filter_config()
642+
635643
# Check that the report filters are properly setup
636644
if REPORT_DATE_FILTER is None or REPORT_TIME_FILTER is None:
637645
raise RuntimeError(

src/browsergym/workarena/tasks/scripts/generate_dashboard_configs.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515

1616
from browsergym.workarena.api.utils import table_api_call, table_column_info
1717
from browsergym.workarena.config import (
18-
REPORT_DATE_FILTER,
1918
REPORT_PATCH_FLAG,
2019
REPORT_RETRIEVAL_MINMAX_CONFIG_PATH,
2120
REPORT_RETRIEVAL_VALUE_CONFIG_PATH,
@@ -44,6 +43,10 @@ def all_configs(self):
4443

4544

4645
def get_report_urls(instance):
46+
# Get the instance report filter config
47+
REPORT_DATE_FILTER, REPORT_TIME_FILTER = instance._get_filter_config()
48+
raise NotImplementedError("TODO: Include the time filter as in dashboard.py")
49+
4750
# Generate a bunch of reports on the fly based on valid table fields
4851
ON_THE_FLY_REPORTS = []
4952
for table in [
@@ -226,7 +229,13 @@ def get_all_configs_by_url(url, is_report):
226229

227230

228231
if __name__ == "__main__":
229-
instance = SNowInstance()
232+
233+
# XXX: Make sure to specific the exact instance on which to generate configs (and not use a random one)
234+
raise NotImplementedError(
235+
"Make sure to specific instance URL and credentials below, then comment this line."
236+
)
237+
instance = SNowInstance(snow_url=None, snow_credentials=None)
238+
230239
reports = get_report_urls(instance)
231240
gen_func = partial(get_all_configs_by_url, is_report=REPORT)
232241

src/browsergym/workarena/tasks/scripts/navigation.py renamed to src/browsergym/workarena/tasks/scripts/generate_navigation_tasks.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66

77

88
def get_all_impersonation_users():
9-
instance = SNowInstance()
9+
raise NotImplementedError(
10+
"Make sure to specific instance URL and credentials below, then comment this line."
11+
)
12+
instance = SNowInstance(snow_url=None, snow_credentials=None)
1013
candidate_users = [
1114
u["first_name"] + " " + u["last_name"]
1215
for u in table_api_call(

src/browsergym/workarena/tasks/scripts/knowledge.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# TODO: Can we delete this file?
12
import json
23
import random
34

@@ -9,9 +10,8 @@
910
from tqdm import tqdm
1011

1112

12-
def generate_all_kb_configs(instance=None, num_configs=1000) -> list[dict]:
13+
def generate_all_kb_configs(instance, num_configs=1000) -> list[dict]:
1314
"""Generate all possible KB configs"""
14-
instance = instance if instance is not None else SNowInstance()
1515
with open(KB_FILEPATH, "r") as f:
1616
kb_entries = json.load(f)
1717
all_configs = []
@@ -33,5 +33,7 @@ def generate_all_kb_configs(instance=None, num_configs=1000) -> list[dict]:
3333

3434

3535
if __name__ == "__main__":
36-
37-
validate_kb_configs()
36+
raise NotImplementedError(
37+
"Make sure to specific instance URL and credentials below, then comment this line."
38+
)
39+
generate_all_kb_configs(instance=SNowInstance(snow_url=None))

0 commit comments

Comments
 (0)