Integrate GST operator agent authenticated browser download flow

This commit is contained in:
A R R R Associates
2026-09-11 22:14:52 +05:30
parent b361a66e44
commit 756daf67da
4 changed files with 338 additions and 101 deletions
@@ -1,4 +1,4 @@
ARRR GST Operator Agent 1.1.0
ARRR GST Operator Agent 1.2.0
Purpose
-------
@@ -3,21 +3,25 @@ from __future__ import annotations
import json
import re
import shutil
import sys
import time
import traceback
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
import sys
import traceback
import requests
VERSION = "1.1.0"
VERSION = "1.2.0"
ROOT = Path(__file__).resolve().parent
DATA = ROOT / "data"
CONFIG_PATH = ROOT / "config.json"
GST_LOGIN_URL = "https://services.gst.gov.in/services/login"
SERVICES_DASHBOARD_URL = "https://services.gst.gov.in/services/auth/dashboard"
RETURN_DASHBOARD_URL = "https://return.gst.gov.in/returns/auth/dashboard"
OFFLINE_DOWNLOAD_PAGE_URL = "https://return.gst.gov.in/returns/auth/gstr/offlinedownload"
GSTR2B_PAGE_URL = "https://gstr2b.gst.gov.in/gstr2b/auth/gstr2bdwld"
GSTR1_URL = "https://return.gst.gov.in/returns/auth/api/offline/download/generate?flag=0&rtn_prd={period}&rtn_typ=GSTR1"
GSTR2A_URL = "https://return.gst.gov.in/returns/auth/api/offline/download/generate?flag=0&rtn_prd={period}&rtn_typ=GSTR2A"
GSTR1_DOWNLOAD_URL = "https://return.gst.gov.in/returns/auth/api/offline/download/url?rtn_prd={period}&rtn_typ=GSTR1&file_num={file_num}"
@@ -90,49 +94,248 @@ def extract_payload(text: str) -> str:
except Exception:
return text or ""
if isinstance(data, dict):
for key in ("data", "payload", "json", "response"):
for key in ("data", "payload", "json", "response", "content", "fileContent"):
value = data.get(key)
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False)
if isinstance(value, str) and value.strip().startswith(("{", "[")):
return value
if any(k in data for k in ("b2b", "b2cl", "b2cs", "cdnr", "gstin")):
return text
return text or ""
def api_text(context, url: str) -> str:
response = context.request.get(url, timeout=90000)
if not response.ok:
raise RuntimeError(f"GST portal request failed ({response.status}) for {url.split('?')[0]}")
return response.text()
def _log(message: str) -> None:
try:
DATA.mkdir(parents=True, exist_ok=True)
with (DATA / "operator_agent.log").open("a", encoding="utf-8") as handle:
handle.write(f"[{now()}] {message}\n")
except Exception:
pass
def download_period(context, work_root: Path, period: str, return_types: list[str], progress) -> list[dict]:
def _show_error(message: str) -> None:
_log("ERROR: " + message)
try:
import tkinter as tk
from tkinter import messagebox
root = tk.Tk()
root.withdraw()
messagebox.showerror("ARRR GST Operator Agent", message)
root.destroy()
except Exception:
pass
def is_access_denied_page(page) -> bool:
try:
url = (page.url or "").lower()
if "accessdenied" in url or "session expired" in url:
return True
try:
text = (page.locator("body").inner_text(timeout=1500) or "").lower()
except Exception:
text = ""
return "access denied" in text or "session expired" in text or "your session expired" in text
except Exception:
return False
def inject_captcha_overlay(page) -> None:
js = r"""
async () => {
return await new Promise((resolve) => {
const old = document.getElementById('arrr-gst-captcha-overlay'); if (old) old.remove();
const overlay = document.createElement('div'); overlay.id='arrr-gst-captcha-overlay';
Object.assign(overlay.style,{position:'fixed',inset:'0',zIndex:'2147483647',background:'rgba(15,23,42,.60)',display:'flex',alignItems:'center',justifyContent:'center',fontFamily:'Arial,sans-serif'});
const box=document.createElement('div'); Object.assign(box.style,{background:'#fff',borderRadius:'12px',boxShadow:'0 20px 60px rgba(0,0,0,.35)',width:'540px',maxWidth:'90vw',padding:'24px',color:'#111827'});
const title=document.createElement('div'); title.innerText='GST Login Captcha Required'; Object.assign(title.style,{fontWeight:'700',fontSize:'20px',marginBottom:'12px'});
const body=document.createElement('div'); body.innerText='Username and password are filled.\\n\\nClick OK, enter CAPTCHA in the GST page, complete OTP if asked, and Login.\\n\\nARRR GST Operator Agent will verify the authenticated session automatically and then download the selected returns.'; Object.assign(body.style,{fontSize:'15px',lineHeight:'1.5',whiteSpace:'pre-line',marginBottom:'18px'});
const btn=document.createElement('button'); btn.innerText='OK - Enter Captcha'; btn.type='button'; Object.assign(btn.style,{background:'#1d4ed8',color:'#fff',border:'0',borderRadius:'8px',padding:'10px 16px',fontWeight:'700',cursor:'pointer'});
btn.onclick=()=>{overlay.remove(); setTimeout(()=>{const c=document.querySelector('#captcha, input[name="captcha"], input[placeholder*="Captcha"]'); if(c){c.scrollIntoView({block:'center'});c.focus();c.click();}},150); resolve(true);};
box.append(title,body,btn); overlay.appendChild(box); document.body.appendChild(overlay); btn.focus();
});
}
"""
try:
page.evaluate(js)
except Exception as exc:
_log(f"Captcha overlay warning: {exc}")
def show_status_overlay(page, message: str, color: str = "#0f172a") -> None:
try:
page.evaluate(
"""({message,color})=>{let b=document.getElementById('arrr-gst-status');if(!b){b=document.createElement('div');b.id='arrr-gst-status';Object.assign(b.style,{position:'fixed',right:'18px',bottom:'18px',zIndex:'2147483647',color:'#fff',borderRadius:'12px',padding:'14px 18px',boxShadow:'0 12px 40px rgba(0,0,0,.35)',fontFamily:'Arial,sans-serif',fontSize:'14px',maxWidth:'440px'});document.body.appendChild(b);}b.style.background=color;b.innerText=message;}""",
{"message": message, "color": color},
)
except Exception:
pass
def _looks_like_valid_ustatus_json(data) -> bool:
if not isinstance(data, dict) or not data:
return False
low = json.dumps(data, default=str).lower()[:3000]
if any(x in low for x in ("access denied", "accessdenied", "session expired", "login required", "invalid session")):
return False
keys = {str(k).lower() for k in data}
if keys & {"utype", "llogin", "lastlogin", "gstin", "gstinno", "username", "legalname", "tradename", "lgnm", "trade_name"}:
return True
meaningful = sum(
1
for value in data.values()
if isinstance(value, (str, int, float, bool)) and str(value).strip() not in ("", "None", "null")
)
return meaningful >= 3
def check_gst_session(page) -> tuple[bool, str]:
for url in (
"https://services.gst.gov.in/services/api/ustatus",
"https://return.gst.gov.in/services/api/ustatus",
):
try:
resp = page.context.request.get(url, timeout=20000)
text = resp.text() or ""
ctype = (resp.headers.get("content-type") or "").lower()
if resp.status == 200 and "json" in ctype:
try:
data = json.loads(text or "{}")
except Exception:
data = None
if _looks_like_valid_ustatus_json(data):
return True, f"authenticated session confirmed from {url}"
except Exception as exc:
_log(f"GST session check warning {url}: {exc}")
return False, "GST authenticated session not confirmed yet"
def wait_for_login(page, progress, timeout_seconds: int = 900) -> None:
inject_captcha_overlay(page)
progress(
percent=10,
stage="Waiting for CAPTCHA / OTP",
message="Username/password filled. Complete CAPTCHA/OTP and login in the visible GST browser. Session is checked automatically.",
)
started = time.time()
while time.time() - started < timeout_seconds:
if is_access_denied_page(page):
raise RuntimeError(
"GST portal reported Access Denied / Session Expired. Close other GST sessions and start the download again."
)
ok, reason = check_gst_session(page)
if ok:
progress(percent=15, stage="GST Login Verified", message=reason)
show_status_overlay(
page,
"GST login verified. ARRR is downloading the selected GST returns now.",
"#065f46",
)
return
time.sleep(5)
raise RuntimeError("GST login/session was not confirmed within 15 minutes.")
def _origin_for_url(url: str) -> str:
for origin in (
"https://gstr2b.gst.gov.in",
"https://return.gst.gov.in",
"https://services.gst.gov.in",
"https://payment.gst.gov.in",
):
if origin.split("//", 1)[1] in (url or ""):
return origin
return "https://return.gst.gov.in"
def open_visible_page(page, url: str, label: str = "GST page", wait_ms: int = 1800) -> None:
try:
page.bring_to_front()
except Exception:
pass
try:
page.goto(url, wait_until="domcontentloaded", timeout=90000)
except Exception:
page.evaluate("url=>window.location.href=url", url)
page.wait_for_load_state("domcontentloaded", timeout=90000)
page.wait_for_timeout(wait_ms)
if is_access_denied_page(page):
raise RuntimeError(f"GST portal returned Access Denied / Session Expired while opening {label}.")
def ensure_origin_page(page, url: str) -> None:
origin = _origin_for_url(url)
if (page.url or "").startswith(origin):
return
if origin == "https://gstr2b.gst.gov.in":
open_visible_page(page, GSTR2B_PAGE_URL, "GSTR-2B download page", 2500)
elif origin == "https://return.gst.gov.in":
open_visible_page(page, RETURN_DASHBOARD_URL, "GST Return Dashboard", 2500)
elif origin == "https://services.gst.gov.in":
open_visible_page(page, SERVICES_DASHBOARD_URL, "GST services dashboard", 1800)
def page_fetch_text(page, url: str, method: str = "GET", payload=None) -> str:
ensure_origin_page(page, url)
result = page.evaluate(
"""async ({url,method,payload})=>{const headers={Accept:'application/json, text/plain, */*'};const opt={method,credentials:'include',headers};if(payload!==null&&payload!==undefined){headers['Content-Type']='application/json;charset=UTF-8';opt.body=JSON.stringify(payload);}const r=await fetch(url,opt);return {status:r.status,ctype:r.headers.get('content-type')||'',text:await r.text(),url:r.url};}""",
{"url": url, "method": method, "payload": payload},
)
status = int(result.get("status") or 0)
text = result.get("text") or ""
ctype = (result.get("ctype") or "").lower()
sample = text.lstrip().lower()
if status >= 400:
raise RuntimeError(f"GST API HTTP {status}: {text[:500]}")
if ("text/html" in ctype or sample.startswith(("<!doctype", "<html"))) and not sample.startswith(("{", "[")):
raise RuntimeError(
"GST returned an HTML/login page instead of return JSON. The portal session/module is not ready."
)
return text
def prepare_portal_context(page, return_types: list[str]) -> None:
selected = {str(x or "").upper() for x in return_types}
if "GSTR1" in selected or "GSTR2A" in selected:
open_visible_page(page, OFFLINE_DOWNLOAD_PAGE_URL, "GST offline return download page", 2500)
if "GSTR2B" in selected:
open_visible_page(page, GSTR2B_PAGE_URL, "GSTR-2B download page", 2500)
if "GSTR3B" in selected:
open_visible_page(page, RETURN_DASHBOARD_URL, "GST Return Dashboard", 2500)
def download_period(page, work_root: Path, period: str, return_types: list[str], progress) -> list[dict]:
raw_dir = work_root / period / "raw"
raw_dir.mkdir(parents=True, exist_ok=True)
selected = {str(x or "").upper() for x in return_types}
downloaded: list[dict] = []
if "GSTR1" in selected:
progress(stage=f"Downloading GSTR-1 {period}", message=f"Reading GSTR-1 for {period}.")
generated = api_text(context, GSTR1_URL.format(period=period))
progress(stage=f"Downloading GSTR-1 {period}", message=f"Generating and downloading GSTR-1 for {period}.")
open_visible_page(page, OFFLINE_DOWNLOAD_PAGE_URL, "GSTR-1 offline download page", 1800)
generated = page_fetch_text(page, GSTR1_URL.format(period=period))
(raw_dir / f"{period}_GSTR1_GENERATE.json").write_text(generated, encoding="utf-8")
file_num = extract_file_num(generated)
content = extract_payload(api_text(context, GSTR1_DOWNLOAD_URL.format(period=period, file_num=file_num)))
content = extract_payload(page_fetch_text(page, GSTR1_DOWNLOAD_URL.format(period=period, file_num=file_num)))
path = raw_dir / f"{period}_GSTR1.json"
path.write_text(content, encoding="utf-8")
downloaded.append({"return_type": "GSTR1", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
if "GSTR2B" in selected:
progress(stage=f"Downloading GSTR-2B {period}", message=f"Reading GSTR-2B for {period}.")
content = api_text(context, GSTR2B_URL.format(period=period))
progress(stage=f"Downloading GSTR-2B {period}", message=f"Downloading GSTR-2B JSON for {period}.")
open_visible_page(page, GSTR2B_PAGE_URL, "GSTR-2B download page", 1800)
content = page_fetch_text(page, GSTR2B_URL.format(period=period))
path = raw_dir / f"{period}_GSTR2B.json"
path.write_text(content, encoding="utf-8")
downloaded.append({"return_type": "GSTR2B", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
if "GSTR3B" in selected:
progress(stage=f"Downloading GSTR-3B {period}", message=f"Reading GSTR-3B for {period}.")
summary = api_text(context, GSTR3B_SUMMARY_URL.format(period=period))
payable = api_text(context, GSTR3B_URL.format(period=period))
progress(stage=f"Downloading GSTR-3B {period}", message=f"Downloading GSTR-3B summary and tax payable for {period}.")
open_visible_page(page, RETURN_DASHBOARD_URL, "GST Return Dashboard", 1800)
summary = page_fetch_text(page, GSTR3B_SUMMARY_URL.format(period=period))
payable = page_fetch_text(page, GSTR3B_URL.format(period=period))
(raw_dir / f"{period}_GSTR3B_SUMMARY.json").write_text(summary, encoding="utf-8")
(raw_dir / f"{period}_GSTR3B_TAXPAYBLE.json").write_text(payable, encoding="utf-8")
try:
@@ -150,44 +353,37 @@ def download_period(context, work_root: Path, period: str, return_types: list[st
downloaded.append({"return_type": "GSTR3B", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
if "GSTR2A" in selected:
progress(stage=f"Requesting GSTR-2A {period}", message=f"Requesting GSTR-2A for {period}.")
content = api_text(context, GSTR2A_URL.format(period=period))
progress(stage=f"Requesting GSTR-2A {period}", message=f"Requesting GSTR-2A offline return for {period}.")
open_visible_page(page, OFFLINE_DOWNLOAD_PAGE_URL, "GSTR-2A offline download page", 1800)
content = page_fetch_text(page, GSTR2A_URL.format(period=period))
path = raw_dir / f"{period}_GSTR2A.json"
path.write_text(content, encoding="utf-8")
downloaded.append({"return_type": "GSTR2A", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
write_json(work_root / period / "download_manifest.json", {
"period": period, "downloaded_at_utc": now(), "source": "gst_lightweight_operator_agent", "downloaded": downloaded
})
write_json(
work_root / period / "download_manifest.json",
{"period": period, "downloaded_at_utc": now(), "source": "arrr_gst_operator_agent_1.2.0", "downloaded": downloaded},
)
return downloaded
def login_complete(page) -> bool:
url = (page.url or "").lower()
if "/dashboard" in url or "/returns" in url:
return True
try:
return bool(page.locator("text=Search Taxpayer").count() and not page.locator("#username").count())
except Exception:
return False
def browser_worker(payload: dict, token: str, path: Path) -> None:
job_id = str(payload.get("jti") or "")
work_root = DATA / "work" / safe(job_id)
package_path = DATA / f"gst_{safe(job_id)}.zip"
def progress(**updates):
current = read_json(path)
current.update(updates)
current["updated_at_utc"] = now()
write_json(path, current)
try:
shutil.rmtree(work_root, ignore_errors=True)
work_root.mkdir(parents=True, exist_ok=True)
def progress(**updates):
current = read_json(path)
current.update(updates)
current["updated_at_utc"] = now()
write_json(path, current)
progress(status="running", percent=5, stage="Opening GST Login", message="Opening GST Portal in visible Chrome/Edge on this workstation.")
progress(status="running", percent=5, stage="Opening GST Login", message="Opening GST Portal in visible Chrome/Edge.")
from playwright.sync_api import sync_playwright
with sync_playwright() as pw:
context = None
errors = []
@@ -196,20 +392,24 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
for channel in ("chrome", "msedge"):
try:
context = pw.chromium.launch_persistent_context(
user_data_dir=str(profile_root / channel), channel=channel, headless=False,
no_viewport=True, accept_downloads=True,
user_data_dir=str(profile_root / f"{channel}_{safe(job_id)}"),
channel=channel,
headless=False,
no_viewport=True,
accept_downloads=True,
args=["--start-maximized", "--no-first-run", "--disable-blink-features=AutomationControlled"],
timeout=45000,
)
break
except Exception as exc:
errors.append(f"{channel}: {exc}")
if context is None:
raise RuntimeError("Could not open installed Chrome or Edge. " + " | ".join(errors))
page = context.pages[0] if context.pages else context.new_page()
page.goto(GST_LOGIN_URL, wait_until="domcontentloaded", timeout=90000)
open_visible_page(page, GST_LOGIN_URL, "GST Login", 1000)
page.locator("#username").wait_for(state="visible", timeout=30000)
page.fill("#username", str(payload.get("username") or ""))
# GST portal has used user_pass for the password field; fall back to generic password locator.
password_filled = False
for selector in ("#user_pass", "input[type=password]"):
try:
@@ -221,25 +421,44 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
except Exception:
pass
if not password_filled:
raise RuntimeError("GST password field could not be located. The portal login page may have changed.")
progress(percent=10, stage="Waiting for CAPTCHA / OTP", message="GST username/password filled. Complete CAPTCHA/OTP in the visible GST browser.")
deadline = time.time() + int(payload.get("login_timeout_seconds") or 900)
while time.time() < deadline:
if login_complete(page):
break
time.sleep(2)
else:
raise RuntimeError("GST login was not completed within 15 minutes.")
raise RuntimeError("GST password field could not be located. The GST login page may have changed.")
wait_for_login(page, progress, int(payload.get("login_timeout_seconds") or 900))
periods = [str(p) for p in payload.get("periods") or []]
return_types = [str(r) for r in payload.get("return_types") or []]
prepare_portal_context(page, return_types)
all_downloaded = []
for index, period in enumerate(periods, 1):
base_pct = 10 + int((index - 1) * 75 / max(1, len(periods)))
progress(percent=base_pct, period=period, period_index=index, period_total=len(periods), stage=f"Period {index}/{len(periods)}", message=f"Downloading selected GST returns for {period}.")
all_downloaded.extend(download_period(context, work_root, period, return_types, progress))
pct = 15 + int((index - 1) * 70 / max(1, len(periods)))
progress(
percent=pct,
period=period,
period_index=index,
period_total=len(periods),
stage=f"Period {index}/{len(periods)}",
message=f"Downloading selected GST returns for {period}.",
)
all_downloaded.extend(download_period(page, work_root, period, return_types, progress))
show_status_overlay(
page,
"Selected GST return downloads are complete. Transferring them to ARRR client storage.",
"#065f46",
)
context.close()
write_json(
work_root / "job_manifest.json",
{
"job_id": job_id,
"gstin": payload.get("gstin"),
"financial_year": payload.get("financial_year"),
"periods": payload.get("periods") or [],
"return_types": payload.get("return_types") or [],
"downloaded": all_downloaded,
"completed_at_utc": now(),
},
)
if package_path.exists():
package_path.unlink()
with zipfile.ZipFile(package_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
@@ -247,11 +466,20 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
if item.is_file():
archive.write(item, item.relative_to(work_root).as_posix())
progress(percent=90, stage="Transferring to Client Storage", message="Uploading GST return package to ERP for transfer to the configured Storage Agent.")
progress(
percent=90,
stage="Transferring to Client Storage",
message="Uploading GST return package to ERP for transfer to configured Storage Agent.",
)
upload_url = str(payload.get("upload_url") or "").strip()
if not upload_url.startswith("https://"):
raise RuntimeError(f"ERP upload URL is invalid: {upload_url or '(empty)'}")
with package_path.open("rb") as handle:
response = requests.post(
str(payload.get("upload_url") or ""), data={"token": token},
files={"package": (package_path.name, handle, "application/zip")}, timeout=240,
upload_url,
data={"token": token},
files={"package": (package_path.name, handle, "application/zip")},
timeout=240,
)
try:
body = response.json()
@@ -259,11 +487,29 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
body = {"ok": False, "error": response.text[:1000]}
if not response.ok or not body.get("ok"):
raise RuntimeError(body.get("error") or f"ERP storage transfer failed with HTTP {response.status_code}.")
progress(status="completed", percent=100, stage="Completed", message="GST returns downloaded and stored in the configured client local storage.", result={"stored": body.get("stored") or {}}, finished_at_utc=now())
progress(
status="completed",
percent=100,
stage="Completed",
message="GST returns downloaded and stored in configured client local storage.",
result={"stored": body.get("stored") or {}},
finished_at_utc=now(),
)
except Exception as exc:
progress = read_json(path)
progress.update({"status": "failed", "percent": 100, "stage": "Failed", "message": str(exc), "error": str(exc), "finished_at_utc": now(), "updated_at_utc": now()})
write_json(path, progress)
current = read_json(path)
current.update(
{
"status": "failed",
"percent": 100,
"stage": "Failed",
"message": str(exc),
"error": str(exc),
"finished_at_utc": now(),
"updated_at_utc": now(),
}
)
write_json(path, current)
_log(traceback.format_exc())
finally:
shutil.rmtree(work_root, ignore_errors=True)
try:
@@ -272,28 +518,6 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
pass
def _log(message: str) -> None:
try:
DATA.mkdir(parents=True, exist_ok=True)
with (DATA / "operator_agent.log").open("a", encoding="utf-8") as handle:
handle.write(f"[{now()}] {message}\n")
except Exception:
pass
def _show_error(message: str) -> None:
_log("ERROR: " + message)
try:
import tkinter as tk
from tkinter import messagebox
root = tk.Tk()
root.withdraw()
messagebox.showerror("ARRR GST Operator Agent", message)
root.destroy()
except Exception:
pass
def redeem_job(token: str) -> dict:
cfg = read_config()
erp = str(cfg.get("erp_base_url") or "").rstrip("/")
@@ -313,8 +537,7 @@ def redeem_job(token: str) -> dict:
if not response.ok or not body.get("ok"):
raise RuntimeError(body.get("error") or "ERP could not authorize this GST download.")
payload = dict(body.get("payload") or {})
job_id = str(payload.get("jti") or "")
if not job_id:
if not str(payload.get("jti") or ""):
raise RuntimeError("ERP did not return a GST job id.")
return payload
@@ -323,14 +546,20 @@ def run_job_foreground(token: str) -> None:
payload = redeem_job(token)
job_id = str(payload.get("jti") or "")
path = job_path(job_id)
initial = {
"status": "queued", "percent": 1, "stage": "Queued",
"message": "GST browser job launched from ARRR ERP.",
"job_id": job_id, "periods": payload.get("periods") or [],
"return_types": payload.get("return_types") or [],
"started_at_utc": now(), "updated_at_utc": now(),
}
write_json(path, initial)
write_json(
path,
{
"status": "queued",
"percent": 1,
"stage": "Queued",
"message": "GST browser job launched from ARRR ERP.",
"job_id": job_id,
"periods": payload.get("periods") or [],
"return_types": payload.get("return_types") or [],
"started_at_utc": now(),
"updated_at_utc": now(),
},
)
_log(f"Starting GST job {job_id}")
browser_worker(payload, token, path)
final = read_json(path)
@@ -384,7 +613,6 @@ def main():
return
print(f"ARRR GST Operator Agent {VERSION}")
print("This agent is launched by the arrrgst:// Windows protocol from ARRR ERP.")
print("Run with --self-test to validate this installation.")
if __name__ == "__main__":
@@ -9,7 +9,7 @@ $Startup = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Startup
$ThumbprintFile = Join-Path $InstallRoot "cert_thumbprint.txt"
$ProtocolKey = "HKCU:\Software\Classes\arrrgst"
Write-Host "ARRR GST Operator Agent 1.1.0 - clean installation" -ForegroundColor Cyan
Write-Host "ARRR GST Operator Agent 1.2.0 - clean installation" -ForegroundColor Cyan
Write-Host "Install root: $InstallRoot"
# Clean legacy v1.0.x listener/startup/certificate/protocol state first.