diff --git a/assets/joachimjordan.jpg b/assets/joachimjordan.jpg new file mode 100644 index 00000000..57f3c53a Binary files /dev/null and b/assets/joachimjordan.jpg differ diff --git a/assets/lukeroy.jpg b/assets/lukeroy.jpg new file mode 100644 index 00000000..0a93fb89 Binary files /dev/null and b/assets/lukeroy.jpg differ diff --git a/content/authors/joachimjordan/_index.md b/content/authors/joachimjordan/_index.md new file mode 100644 index 00000000..cf8d4ce7 --- /dev/null +++ b/content/authors/joachimjordan/_index.md @@ -0,0 +1,6 @@ +--- +title: "Joachim Jordan" +text: "Tech Lead Code Engine Platform @ IBM Cloud" +--- + +Welcome to my blog post feed. diff --git a/content/authors/lukeroy/_index.md b/content/authors/lukeroy/_index.md index fe694e8b..8f0ca2c3 100644 --- a/content/authors/lukeroy/_index.md +++ b/content/authors/lukeroy/_index.md @@ -1,6 +1,6 @@ --- title: "Luke Roy" -text: "Cloud Sofware Engineer @ IBM Cloud Code Engine" +text: "Cloud Software Engineer @ IBM Cloud Code Engine" --- -Welcome to my blog post feed. +Welcome to my blog post feed. diff --git a/content/posts/remote-bob-run-bob-shell-in-the-cloud/01-status.png b/content/posts/remote-bob-run-bob-shell-in-the-cloud/01-status.png new file mode 100644 index 00000000..c4b07473 Binary files /dev/null and b/content/posts/remote-bob-run-bob-shell-in-the-cloud/01-status.png differ diff --git a/content/posts/remote-bob-run-bob-shell-in-the-cloud/02-setup.png b/content/posts/remote-bob-run-bob-shell-in-the-cloud/02-setup.png new file mode 100644 index 00000000..97b573fc Binary files /dev/null and b/content/posts/remote-bob-run-bob-shell-in-the-cloud/02-setup.png differ diff --git a/content/posts/remote-bob-run-bob-shell-in-the-cloud/03-new-session.png b/content/posts/remote-bob-run-bob-shell-in-the-cloud/03-new-session.png new file mode 100644 index 00000000..cd8ebd56 Binary files /dev/null and b/content/posts/remote-bob-run-bob-shell-in-the-cloud/03-new-session.png differ diff --git a/content/posts/remote-bob-run-bob-shell-in-the-cloud/04-browser-login.png b/content/posts/remote-bob-run-bob-shell-in-the-cloud/04-browser-login.png new file mode 100644 index 00000000..f1bea43c Binary files /dev/null and b/content/posts/remote-bob-run-bob-shell-in-the-cloud/04-browser-login.png differ diff --git a/content/posts/remote-bob-run-bob-shell-in-the-cloud/05-bob-shell-welcome.png b/content/posts/remote-bob-run-bob-shell-in-the-cloud/05-bob-shell-welcome.png new file mode 100644 index 00000000..035bc0f2 Binary files /dev/null and b/content/posts/remote-bob-run-bob-shell-in-the-cloud/05-bob-shell-welcome.png differ diff --git a/content/posts/remote-bob-run-bob-shell-in-the-cloud/06-bob-shell-capabilities.png b/content/posts/remote-bob-run-bob-shell-in-the-cloud/06-bob-shell-capabilities.png new file mode 100644 index 00000000..0cc57601 Binary files /dev/null and b/content/posts/remote-bob-run-bob-shell-in-the-cloud/06-bob-shell-capabilities.png differ diff --git a/content/posts/remote-bob-run-bob-shell-in-the-cloud/07-connect.png b/content/posts/remote-bob-run-bob-shell-in-the-cloud/07-connect.png new file mode 100644 index 00000000..a61e26a1 Binary files /dev/null and b/content/posts/remote-bob-run-bob-shell-in-the-cloud/07-connect.png differ diff --git a/content/posts/remote-bob-run-bob-shell-in-the-cloud/08-ce-job-running.png b/content/posts/remote-bob-run-bob-shell-in-the-cloud/08-ce-job-running.png new file mode 100644 index 00000000..69515f58 Binary files /dev/null and b/content/posts/remote-bob-run-bob-shell-in-the-cloud/08-ce-job-running.png differ diff --git a/content/posts/remote-bob-run-bob-shell-in-the-cloud/09-ce-job-runs.png b/content/posts/remote-bob-run-bob-shell-in-the-cloud/09-ce-job-runs.png new file mode 100644 index 00000000..3c9a2cbe Binary files /dev/null and b/content/posts/remote-bob-run-bob-shell-in-the-cloud/09-ce-job-runs.png differ diff --git a/content/posts/remote-bob-run-bob-shell-in-the-cloud/featured.jpg b/content/posts/remote-bob-run-bob-shell-in-the-cloud/featured.jpg new file mode 100644 index 00000000..6d939a87 Binary files /dev/null and b/content/posts/remote-bob-run-bob-shell-in-the-cloud/featured.jpg differ diff --git a/content/posts/remote-bob-run-bob-shell-in-the-cloud/index.md b/content/posts/remote-bob-run-bob-shell-in-the-cloud/index.md new file mode 100644 index 00000000..4361ad06 --- /dev/null +++ b/content/posts/remote-bob-run-bob-shell-in-the-cloud/index.md @@ -0,0 +1,271 @@ +--- +title: "Remote Bob: Run Bob Shell in IBM Cloud Code Engine" +date: 2026-09-01 +description: "Spin up a full Bob Shell terminal running autonomously in IBM Cloud Code Engine — sandboxed, persistent, and accessible from any browser in under 20 minutes." +tags: ["Bob", "code engine", "serverless", "AI", "automation"] +featureImage: "featured.jpg" +draft: false +authors: ["lukeroy", "joachimjordan"] +--- + +## Introduction + +[IBM Cloud Code Engine](https://www.ibm.com/products/code-engine) is IBM's fully managed, strategic serverless platform. It lets you run containerised workloads — applications, batch jobs, and functions — without ever touching infrastructure. Code Engine scales to zero when idle, scales out automatically under load, and charges only for the compute you actually consume. It is the platform of choice for teams that want the power of Kubernetes without the operational overhead. + +[Bob](https://bob.ibm.com/) is IBM's AI software engineering assistant. Bob understands your codebase, executes multi-step agentic tasks, runs CLI commands, manages secrets, and integrates with the full IBM Cloud ecosystem through its skill and MCP-server plugin architecture. Bob Shell — the terminal-first interface to Bob — takes this further: it is a full interactive environment where Bob operates as an autonomous agent, capable of writing, running, and iterating on code with no human in the loop for each individual step. + +Together, Code Engine and Bob open up a compelling new pattern: **running Bob Shell as a serverless cloud workload**. This is what Remote Bob is about. + +Remote Bob gives you a **full Bob Shell terminal running in IBM Cloud Code Engine**, accessible from your local browser. One command provisions the infrastructure. A second command starts a session and opens the terminal. Close the browser tab — the session keeps running in the cloud. Reopen it with a single command. When you are done, tear everything down cleanly or simply let it sit at zero cost until you need it again. + +## Why Run Bob in the Cloud? + +Running an AI coding agent locally is fine for interactive work, but several scenarios benefit from — or require — a cloud-hosted agent: + +### Sandboxed Execution + +Bob can write and execute arbitrary code. When working on exploratory or risky tasks — generating and running build scripts, testing untrusted dependencies, bulk-modifying a large codebase — you may want that execution to happen in an isolated environment, not on your laptop. A Code Engine job run is a disposable container: every session starts from a clean image, and when the job ends the container is gone. Nothing persists to your local machine. + +### Autonomous Long-Running Tasks + +Some tasks take hours: large-scale refactors, multi-stage data pipelines, training-data preprocessing, automated code reviews across hundreds of files. Running these on your laptop ties up your machine, drains the battery, and breaks if you close the lid. Code Engine job runs are designed for exactly this pattern — they run to completion in the cloud while you do other things. Remote Bob makes it trivial to attach a terminal to that running job whenever you want to check progress. + +### Remote and Multi-Device Access + +With Remote Bob, your Bob Shell session is not tied to a single machine. Start a session from your workstation in the morning, reconnect from a laptop on the go, and check in from a coffee shop in the afternoon — all connecting to the same live session. The terminal state, tmux layout, and open buffers are all preserved in the cloud-hosted container. + +### High-Performance Compute on Demand + +Bob doesn't just read code — it builds it, tests it, and runs it. Agentic tasks can be surprisingly compute-intensive: compiling a large monorepo, running a full test suite, processing large datasets as part of an automated pipeline, or parallelising tool calls across many files simultaneously. On a laptop, these tasks compete with everything else running on your machine. In a Code Engine job run, Bob gets dedicated resources. + +The job-agent container supports up to **12 vCPUs and 48 GB of memory** per session. Two lines in `.env` are all it takes: + +```bash +DEFAULT_CPU=12 +DEFAULT_MEMORY=48G +``` + +The next `--new-session` picks up the new sizing automatically — no rebuilding, no re-provisioning. Bob gets exactly the headroom it needs for the duration of the session, and the job costs nothing when it is not running. + +### Cost Efficiency + +Because the apiserver scales to zero between sessions and job runs are stopped when not needed, the idle cost is effectively zero. You pay only for active compute time — typically a few cents per hour of actual use. + +--- + +## Prerequisites + +Before you begin, make sure you have the following in place. You will need a Bob subscription (or free trial), an IBM Cloud account with the right permissions, two API keys (one for IBM Cloud, one for Bob Shell), a gateway password you choose yourself, and a handful of CLI tools that the launcher script relies on. Everything else — the Code Engine project, container images, secrets — is created automatically by `./remote-bob --setup`. + +- **Bob subscription** — An active Bob subscription or [free trial](https://bob.ibm.com) is required to obtain a Bob Shell API key +- **IBM Cloud account** — With permission to create Code Engine projects and Container Registry namespaces +- **IBM Cloud API key** — Needs Code Engine Writer + Container Registry Writer roles +- **Bob Shell API key** — From [bob.ibm.com](https://bob.ibm.com) → Settings → API Keys +- **`ibmcloud` CLI** — [Install](https://cloud.ibm.com/docs/cli); the `code-engine` plugin is installed automatically by the launcher +- **`jq`**, **`curl`**, **`openssl`** — `brew install jq` / `apt install jq`; curl and openssl are pre-installed on macOS and most Linux distros +- **Google Chrome** — Auto-detected on macOS and Linux. Chrome is the most widely used browser and is what the launcher opens automatically; the browser client is a standard HTML/WebSocket page and can be opened in any modern browser manually if you prefer a different one + +--- + +## Quickstart + +### Step 1: Clone the repository and configure + +```bash +git clone https://github.com/IBM/CodeEngine +cd CodeEngine/remote-bob + +# Copy the config template and fill in the two API keys and gateway password +cp .env.template .env +``` + +Open `.env` and set the three required values: + +```bash +BOBSHELL_API_KEY=your-bob-shell-api-key # from bob.ibm.com → Settings → API Keys +GATEWAY_PASSWORD=choose-any-password # protects the browser terminal endpoint +IBMCLOUD_API_KEY=your-ibm-cloud-api-key # needs Code Engine Writer + ICR Writer +``` + +That is it. Everything else — region, resource group name, container image tags, CPU and memory — has sensible defaults and the system works out of the box without touching any of them. That said, all of these values are fully configurable: adjust the region, resource sizing, or any other parameter to match your preferences, and the launcher will pick up the changes on the next run. + +### Step 2: Run status to log in and see current state + +```bash +./remote-bob +``` + +Running `./remote-bob` with no arguments logs in to IBM Cloud and prints a status summary. On first run it creates the resource group and Code Engine project, then tells you what to do next. + +![Status output showing IBM Cloud login and initial setup](01-status.png) + +### Step 3: Provision infrastructure and build images + +```bash +./remote-bob --setup +``` + +`--setup` is idempotent: it creates the Code Engine project, provisions secrets, builds the apiserver container image and the job-agent container image, and deploys the apiserver as a Code Engine application. The first run takes a few minutes because both images are built from source on Code Engine. Subsequent runs skip steps that are already complete and only rebuild if source code has changed. + +![--setup output: building apiserver and job-agent, deploying application](02-setup.png) + +At the end of setup you will see the apiserver URL and a prompt to run `--new-session`. + +### Step 4: Start a session + +```bash +./remote-bob --new-session +``` + +This submits a new Code Engine job run, waits for the agent to connect to the apiserver (typically under 30 seconds), and then opens a Chrome window pointed at the browser client. + +![--new-session output: job submitted, agent ready after 21s, browser opening](03-new-session.png) + +### Step 5: Log in and start working + +The browser client is a self-contained HTML page served from `file://` — no web server required. It prompts for your gateway password, then opens a full xterm.js terminal connected over WebSocket to your Bob Shell session running in Code Engine. + +![Browser login screen for the Remote Bob gateway](04-browser-login.png) + +Once authenticated, you are in Bob Shell — the same experience you would have locally, but running as an isolated job in the cloud. + +![Bob Shell welcome screen running inside Code Engine](05-bob-shell-welcome.png) + +Bob Shell in the cloud has the full set of capabilities: writing and running code in any language, executing CLI commands, managing files, calling external APIs, and working through multi-step agentic tasks autonomously. + +![Bob Shell capabilities — code, architecture, tooling, data, configuration](06-bob-shell-capabilities.png) + +### Step 6: Disconnect and reconnect + +Close the browser tab at any time. The Code Engine job run keeps running — the tmux session inside is untouched. + +To reconnect from any machine: + +```bash +./remote-bob --connect +``` + +`--connect` queries IBM Cloud for the live session, constructs the browser client URL, and opens Chrome again. No re-provisioning, no waiting. + +![--connect output: live session found, browser opening](07-connect.png) + +### Step 7: View the running job in IBM Cloud + +You can also monitor the session directly from the IBM Cloud console. Navigate to your Code Engine project and open the Jobs section. You will see the job run listed as **Running**. + +![Code Engine console showing the job run in Running state](08-ce-job-running.png) + +Only one session can be active at a time — `--new-session` will refuse to start if one is already running. Once you end a session with `--end-session`, you can start a fresh one. The IBM Cloud console keeps a history of all job runs, showing completed sessions alongside the current one. + +![Code Engine console showing multiple job runs — one running, one succeeded](09-ce-job-runs.png) + +### Step 8: End the session and clean up + +```bash +# End the current session (stops job runs; infrastructure stays for fast restart) +./remote-bob --end-session + +# Start another session without rebuilding (seconds, not minutes) +./remote-bob --new-session + +# Remove all IBM Cloud resources when finished +./remote-bob --clean +``` + +`--end-session` gracefully disconnects the agent and deletes all job runs. The apiserver application and Code Engine project remain, so the next `--new-session` starts in seconds rather than minutes. + +`--clean` removes everything: job runs, job definition, application, secrets, Code Engine project, and resource group. It is only needed if you want a complete teardown and to remove all IBM Cloud resources entirely. If you simply end the session without cleaning up, the apiserver scales to zero and no job runs are executing — so no costs are generated. You can start a new session at any time with `--new-session` without going through setup again. + +--- + +## How It Works + +``` +Browser (Chrome, file:// page, xterm.js) + │ WebSocket /ws/browser?token=&agent=&service=ttyd + ▼ +Apiserver (Go, IBM Code Engine app, scales to zero) + │ auth: POST /auth/login → 60s WS token + │ POST /auth/runs → per-run agent token (HMAC-signed) + │ relay: opaque frame proxy — text + binary frames preserved verbatim + │ WebSocket /ws/agent (Bearer ) + ▼ +Job-agent (Go, IBM Code Engine job run) + │ dials apiserver on startup, registers services + │ opens upstream ttyd connection per relay request + ▼ +ttyd → tmux → Bob Shell +``` + +**Apiserver** is a thin authenticated relay deployed as a Code Engine application. It maintains an in-memory agent registry, issues short-lived single-use tokens, and proxies WebSocket frames between the browser and the job-agent without inspecting the payload. It scales to zero when no agent is connected — zero idle cost. + +**Job-agent** is a Go binary deployed as a Code Engine job run. On startup it dials the apiserver control WebSocket, registers the `ttyd` service, and handles relay connections by piping raw frames between the apiserver and a local `ttyd` process. It runs `tmux` → Bob Shell inside `ttyd` and provides a health endpoint for the job run lifecycle. Code Engine job runs have a maximum execution time of 24 hours, which is also the maximum lifetime of a Remote Bob session. For workloads that must run beyond that limit, Code Engine's [daemon run mode](https://cloud.ibm.com/docs/codeengine?topic=codeengine-job-daemon) is the path forward. + +**Browser client** is a single self-contained HTML file loaded from `file://`. It authenticates with the gateway password, opens a WebSocket relay connection, and renders the terminal using xterm.js — no server-side rendering, no CDN dependencies. + +**Secrets** are stored in two IBM Code Engine secrets injected as environment variables: +- `remote-bob-gateway` — `GATEWAY_PASSWORD`, `ENCRYPTION_KEY` +- `remote-bob-bobshell` — `BOBSHELL_API_KEY` + +--- + +## Use Cases in Depth + +### Sandboxed Agentic Coding + +Give Bob a task that involves writing and running unknown code — for example, "generate a data cleaning pipeline for this CSV schema and run it." In a cloud job, that code executes in an isolated container with no access to your local filesystem, no persistent state beyond the job lifetime, and no way to interfere with your development environment. When the job ends, the container is gone. + +### Autonomous Long-Running Agents + +Kick off a task that takes hours — a large-scale codebase migration, an exhaustive test suite run, a multi-repository dependency audit — and let it run overnight. You do not need to keep your laptop awake. Check in from any device with `./remote-bob --connect` to see where Bob got to. For compute-heavy workloads, scale the job up to 12 vCPUs and 48 GB of memory by setting `DEFAULT_CPU` and `DEFAULT_MEMORY` in `.env` before starting the session. + +### CI/CD Integration + +The `--new-session` flow is scriptable. You can trigger a Remote Bob session from a GitHub Actions workflow, inject environment variables via `--env-from-secret`, let Bob run a task, and collect results — all without provisioning or managing long-lived infrastructure. + +### Team Shared Agents + +Remote Bob is personal by design — the IBM Cloud account and Bob subscription belong to whoever ran `--setup`, and one session runs at a time. That said, multiple people can `--connect` to the same running session using the gateway password. This makes it straightforward to hand off a long-running task mid-flight, review what Bob is working on in real time, or demonstrate a live agentic workflow to a colleague — without any re-provisioning. + +--- + +## Command Reference + +- **`./remote-bob`** — Log in, print infrastructure + session status, suggest next step +- **`./remote-bob --setup`** — Provision IBM Cloud resources and build container images. Idempotent — safe to re-run after code changes. +- **`./remote-bob --new-session`** — Submit a job run, wait for the agent to connect, open Chrome +- **`./remote-bob --connect`** — Find the live session and reopen Chrome. No re-provisioning. +- **`./remote-bob --end-session`** — Stop all job runs gracefully. Infrastructure remains for a fast restart. +- **`./remote-bob --clean`** — Delete all provisioned IBM Cloud resources + +All commands accept `--config=FILE` to use a config file other than `.env`. + +--- + +## Conclusion + +Remote Bob demonstrates a pattern that becomes increasingly useful as AI agents take on longer and more complex tasks: **decouple the agent runtime from the developer's local machine** and let serverless infrastructure handle the lifecycle. + +IBM Cloud Code Engine is a natural fit for this. Job runs give you isolated, ephemeral containers that start in seconds and cost nothing when idle. The apiserver application scales to zero between sessions. The total infrastructure footprint is minimal, and the total idle cost is zero. + +Two API keys, one gateway password, one command to provision, one command to start — and Bob is running in the cloud. No infrastructure to manage, no servers to maintain, and nothing running idle when you are not using it. That is the promise of Remote Bob, and Code Engine is what makes it possible. + +## Try It — and Make It Your Own + +Get the code from the [CodeEngine sample repository](https://github.com/IBM/CodeEngine), fill in the two API keys and gateway password, and run `--setup`. From there, the system is yours to extend. + +**Add the tools you need.** The job-agent is a standard container. If Bob needs a tool — a specific CLI, a language runtime, a build toolchain — add it to the Dockerfile. The next `--setup` rebuilds the image and every subsequent session has it available. + +**Persist your work across sessions.** By default each session starts from a clean container. If you want Bob to pick up where it left off — keeping a workspace, a git clone, or accumulated context — connect a [Code Engine persistent data store](https://cloud.ibm.com/docs/codeengine?topic=codeengine-persistent-data-store) to the job and mount it into the workspace directory. Files written there survive across sessions. For larger datasets or shared outputs between sessions, Code Engine also integrates natively with IBM Cloud Object Storage — mount a bucket directly into the job container and Bob can read and write files at scale without any additional infrastructure. + +**Run your own MCP server.** Deploy any MCP server as a Code Engine application alongside the apiserver, wire up the URL as an environment variable in the job-agent secret, and Bob will have access to it in every session. Your tools, your APIs, your data sources — all reachable from the cloud agent without exposing anything to the public internet. + +Remote Bob is a starting point. The session lifecycle, the relay architecture, and the launcher script are all open and composable. Take it, adapt it, and build the cloud agent environment that fits your workflow. + +## Resources + +- [IBM Cloud Code Engine](https://www.ibm.com/products/code-engine) +- [Code Engine Documentation](https://cloud.ibm.com/docs/codeengine) +- [Code Engine Sample Repository](https://github.com/IBM/CodeEngine) — Remote Bob source code lives here +- [Bob (watsonx Code Assistant)](https://www.ibm.com/products/watsonx-code-assistant) +- [IBM Cloud CLI](https://cloud.ibm.com/docs/cli) diff --git a/data/authors/joachimjordan.toml b/data/authors/joachimjordan.toml new file mode 100644 index 00000000..21e48680 --- /dev/null +++ b/data/authors/joachimjordan.toml @@ -0,0 +1,6 @@ +name = "Joachim Jordan" +bio = "Tech Lead Code Engine Platform @ IBM Cloud" +image = "joachimjordan.jpg" + +# Add external links (this will automatically generate clickable icons under the author name) +[links] diff --git a/data/authors/josip-ledic.toml b/data/authors/josip-ledic.toml new file mode 100644 index 00000000..4b05c40c --- /dev/null +++ b/data/authors/josip-ledic.toml @@ -0,0 +1,5 @@ +name = "Josip Ledic" +bio = "Software Engineer @ IBM Cloud Code Engine" + +# Add external links (this will automatically generate clickable icons under the author name) +[links] diff --git a/data/authors/lukeroy.toml b/data/authors/lukeroy.toml new file mode 100644 index 00000000..5815edd5 --- /dev/null +++ b/data/authors/lukeroy.toml @@ -0,0 +1,8 @@ +name = "Luke Roy" +bio = "Cloud Software Engineer @ IBM Cloud Code Engine" +image = "lukeroy.jpg" + +# Add external links (this will automatically generate clickable icons under the author name) +[links] + linkedin = "https://www.linkedin.com/in/luke-roy" + github = "https://github.com/lukeroy" diff --git a/remote-bob/.ceignore b/remote-bob/.ceignore new file mode 100644 index 00000000..34ab77a8 --- /dev/null +++ b/remote-bob/.ceignore @@ -0,0 +1,24 @@ +# CE source-upload ignore rules for the job-agent build context. +# The job-agent Dockerfile only needs job-agent/ — exclude everything else. + +# Secrets and local config +.env +.env.template + +# Other service source (not needed for job-agent build) +apiserver/ + +# Static browser client (not needed in the container) +browser-client/ + +# Launcher script and docs +remote-bob +README.md +.gitignore +.ceignore + +# Runtime scratch and caches +tmp/ +bobshell-*.tgz +*.log +.DS_Store diff --git a/remote-bob/.env.template b/remote-bob/.env.template new file mode 100644 index 00000000..34071441 --- /dev/null +++ b/remote-bob/.env.template @@ -0,0 +1,55 @@ +# Remote Bob — Configuration Template +# Copy this file to .env and fill in the three required keys. +# +# Workflow: +# cp .env.template .env +# # edit .env — set the three required keys below +# ./remote-bob --setup # first time: provision infra + build images +# ./remote-bob --new-session # start a job run and open the browser +# ./remote-bob --connect # reopen the browser (session still running) +# ./remote-bob --end-session # kill the job run (infra stays) +# ./remote-bob --clean # remove all IBM Cloud resources +# ./remote-bob # show current status + +# ─── Required ──────────────────────────────────────────────────────────────── + +# Bob Shell API key — get from https://bob.ibm.com (Settings → API Keys) +BOBSHELL_API_KEY=your-bob-shell-api-key-here + +# Basic-auth password shown in the browser login prompt +GATEWAY_PASSWORD=your-gateway-password-here + +# IBM Cloud API key with Code Engine and Container Registry permissions +IBMCLOUD_API_KEY=your-ibmcloud-api-key-here + +# ─── Optional overrides (defaults shown) ───────────────────────────────────── + +# IBM Cloud region +# CE_REGION=us-east + +# Code Engine project (created if it does not exist) +# CE_PROJECT=remote-bob--ce-project + +# IBM Cloud resource group (created if it does not exist) +# RESOURCE_GROUP=remote-bob--rg + +# CE app name for the apiserver +# APISERVER_APP_NAME=remote-bob-apiserver + +# CE job name for the job-agent +# JOB_NAME=remote-bob-job-agent + +# Job-agent run resources +# DEFAULT_CPU=1 +# DEFAULT_MEMORY=2G +# DEFAULT_TIMEOUT=86400 + +# Path to Chrome binary (auto-detected on macOS and Linux if not set) +# CHROME_BIN= + +# Chrome remote-debugging port (CDP / E2E tooling) +# CHROME_DEBUG_PORT= + +# HMAC signing key for run tokens — generated fresh in-memory each run. +# Set only if you need token stability across apiserver restarts. +# ENCRYPTION_KEY= diff --git a/remote-bob/.gitignore b/remote-bob/.gitignore new file mode 100644 index 00000000..bdc153a0 --- /dev/null +++ b/remote-bob/.gitignore @@ -0,0 +1,27 @@ +# ── Secrets & local config ─────────────────────────────────────────────────── +# .env holds real credentials. Never commit it; only .env.template is tracked. +.env + +# ── Go build artefacts ─────────────────────────────────────────────────────── +# Compiled binaries produced by `go build`. +apiserver/apiserver +job-agent/job-agent + +# Go vendor directories (populated by `go mod vendor`). +apiserver/vendor/ +job-agent/vendor/ + +# ── Bob Shell package cache ────────────────────────────────────────────────── +# run.sh downloads and caches the Bob Shell tarball here before passing it +# to the job-agent Docker build via --build-arg BOBSHELL_TARBALL. +bobshell-*.tgz + +# ── Runtime scratch directories ────────────────────────────────────────────── +# Chrome user-data dirs created by run.sh for local CDP / E2E sessions. +tmp/ + +# ── Logs ───────────────────────────────────────────────────────────────────── +*.log + +# ── OS cruft ───────────────────────────────────────────────────────────────── +.DS_Store diff --git a/remote-bob/README.md b/remote-bob/README.md new file mode 100644 index 00000000..2e5d18fa --- /dev/null +++ b/remote-bob/README.md @@ -0,0 +1,147 @@ +# Remote Bob + +Remote Bob gives you a **full Bob Shell terminal running in IBM Cloud Code Engine**, accessible from your local browser. One command provisions the infrastructure; a second command opens the terminal. Close the browser — the session keeps running in the cloud. Open it again with a single command. + +--- + +## Prerequisites + +| Requirement | Notes | +|---|---| +| **IBM Cloud account** | With permission to create Code Engine projects and Container Registry namespaces | +| **IBM Cloud API key** | Needs Code Engine Writer + Container Registry Writer roles | +| **Bob Shell API key** | Get from [bob.ibm.com](https://bob.ibm.com) → Settings → API Keys | +| **`ibmcloud` CLI** | [Install](https://cloud.ibm.com/docs/cli) — the `code-engine` plugin is installed/updated automatically | +| **`jq`** | `brew install jq` / `apt install jq` | +| **`curl`**, **`openssl`** | Included on macOS and most Linux distros | +| **Google Chrome** | Auto-detected on macOS (`/Applications/Google Chrome.app`) and Linux (`google-chrome`) | + +--- + +## Quickstart + +```bash +# 1. Copy the config template and fill in your three keys +cp .env.template .env +# BOBSHELL_API_KEY=... +# GATEWAY_PASSWORD=choose-any-password +# IBMCLOUD_API_KEY=... + +# 2. Provision IBM Cloud resources and build container images (~20 min first time) +./remote-bob --setup + +# 3. Start a session — opens a Chrome terminal window +./remote-bob --new-session + +# 4. Close the browser window when done. The session keeps running in the cloud. + +# 5. Reopen the browser for the same running session +./remote-bob --connect + +# 6. End the session (stops the job run; infrastructure stays for fast restart) +./remote-bob --end-session + +# 7. Start another session without rebuilding +./remote-bob --new-session + +# 8. Remove all IBM Cloud resources when finished +./remote-bob --clean + +# Check current status at any time +./remote-bob +``` + +--- + +## Command reference + +| Command | What it does | +|---|---| +| `--setup` | Provisions IBM Cloud resources (resource group, CE project, secrets) and builds the apiserver and job-agent container images. **Idempotent** — safe to re-run after code changes. | +| `--new-session` | Submits a new job run, waits for the agent to connect, and opens Chrome. Requires `--setup` to have completed. Prevents starting a second session if one is already live. | +| `--connect` | Queries IBM Cloud for the live session and reopens the Chrome terminal. No re-provisioning. | +| `--end-session` | Gracefully disconnects the agent and deletes all job runs. Leaves the app and job definition in place so the next `--new-session` starts in seconds. | +| `--clean` | Deletes all provisioned IBM Cloud resources: job runs, job, app, secrets, CE project, resource group. | +| *(no args)* | Logs in and prints current infrastructure + session status with a suggested next command. | + +All commands accept `--config=FILE` to use a config file other than `.env`. + +--- + +## Configuration + +Copy `.env.template` to `.env`. Only the three required keys need to be set: + +```bash +# Required +BOBSHELL_API_KEY=your-bob-shell-api-key +GATEWAY_PASSWORD=any-password-you-choose +IBMCLOUD_API_KEY=your-ibm-cloud-api-key +``` + +Everything else has sensible defaults (region `us-east`, auto-generated `ENCRYPTION_KEY`, etc.). See `.env.template` for the full list of optional settings such as `BOB_MODE`, `DEFAULT_CPU`, `DEFAULT_MEMORY`, and `CHROME_BIN`. + +**The `.env` file is never modified at runtime.** Session state (app URL, agent ID) is retrieved live from IBM Cloud on every invocation. + +--- + +## How it works + +``` +Browser (Chrome, file:// page, xterm.js) + │ WebSocket /ws/browser?token=&agent=&service=ttyd + ▼ +Apiserver (Go, IBM Code Engine app, scales to zero) + │ auth: POST /auth/login → 60s WS token + │ POST /auth/runs → per-run agent token (HMAC-signed) + │ relay: opaque frame proxy — text + binary frames preserved verbatim + │ WebSocket /ws/agent (Bearer ) + ▼ +Job-agent (Go, IBM Code Engine job run) + │ dials apiserver on startup, registers services + │ opens upstream ttyd connection per relay request + ▼ +ttyd → tmux → Bob Shell +``` + +**Apiserver** is a thin authenticated relay deployed as a Code Engine application. It holds an in-memory agent registry, issues short-lived tokens, and proxies WebSocket frames between the browser and the job-agent without inspecting the payload. It has no database and no persistence; it shuts itself down when the last agent disconnects. + +**Job-agent** is a Go binary deployed as a Code Engine job run. On startup it dials the apiserver control WebSocket, registers the `ttyd` service, and handles relay connections by piping raw frames between the apiserver and a local `ttyd` process. It runs `tmux` → Bob Shell inside `ttyd` and serves a health endpoint for the job run lifecycle. An idle timeout shuts it down automatically when not in use. + +**Browser client** is a single self-contained HTML file loaded from `file://`. It authenticates with the gateway password, opens a WebSocket relay, and renders the terminal using xterm.js. No server-side rendering, no CDN dependencies. + +**Secrets** are stored in two IBM Code Engine secrets injected as environment variables: +- `remote-bob-gateway` — `GATEWAY_PASSWORD`, `ENCRYPTION_KEY` +- `remote-bob-bobshell` — `BOBSHELL_API_KEY` + +--- + +## Repository layout + +``` +remote-bob/ +├── apiserver/ # Go apiserver — auth, registry, relay +│ ├── cmd/apiserver/ +│ ├── internal/ +│ ├── Dockerfile +│ └── go.mod +├── job-agent/ # Go job-agent — tunnel daemon, ttyd, tmux, Bob Shell +│ ├── cmd/job-agent/ +│ ├── internal/tunnel/ +│ ├── Dockerfile +│ └── go.mod +├── browser-client/ # Static xterm.js terminal page (file://) +│ └── single-session.html +├── remote-bob # Launcher — all commands +├── .env.template # Config template +└── README.md +``` + +--- + +## Building and testing the Go modules + +```bash +cd remote-bob/apiserver && go build ./... && go test ./... +cd remote-bob/job-agent && go build ./... && go test ./... +``` diff --git a/remote-bob/apiserver/.ceignore b/remote-bob/apiserver/.ceignore new file mode 100644 index 00000000..c9b58416 --- /dev/null +++ b/remote-bob/apiserver/.ceignore @@ -0,0 +1,6 @@ +# CE source-upload ignore rules for the apiserver build context. +# --build-source points to remote-bob/apiserver/ — this file lives there. +# Dependencies are fetched by `go mod download` inside the build. +.env* +*.log +tmp/ diff --git a/remote-bob/apiserver/.dockerignore b/remote-bob/apiserver/.dockerignore new file mode 100644 index 00000000..fa9cd540 --- /dev/null +++ b/remote-bob/apiserver/.dockerignore @@ -0,0 +1,7 @@ +# apiserver build context — Code Engine source builds upload this directory. +# Exclude the built binary (rebuilt in the image), env/template files, and +# local cruft. Dependencies are fetched by `go mod download` inside the build. +apiserver +.env* +*.log +tmp/ diff --git a/remote-bob/apiserver/Dockerfile b/remote-bob/apiserver/Dockerfile new file mode 100644 index 00000000..7f520826 --- /dev/null +++ b/remote-bob/apiserver/Dockerfile @@ -0,0 +1,22 @@ +# Stage 1: build +FROM quay.io/projectquay/golang:1.26 AS build +USER root +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /apiserver ./cmd/apiserver + +# Stage 2: runtime +FROM registry.access.redhat.com/ubi9/ubi-minimal:latest +RUN microdnf install -y ca-certificates && microdnf clean all + +# Create non-root user +RUN useradd -m -u 1001 appuser + +COPY --from=build --chown=appuser:appuser /apiserver /apiserver + +EXPOSE 8080 + +USER 1001 +ENTRYPOINT ["/apiserver"] diff --git a/remote-bob/apiserver/cmd/apiserver/main.go b/remote-bob/apiserver/cmd/apiserver/main.go new file mode 100644 index 00000000..920b02d8 --- /dev/null +++ b/remote-bob/apiserver/cmd/apiserver/main.go @@ -0,0 +1,119 @@ +package main + +import ( + "context" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/CodeEngine/remote-bob-apiserver/internal/api" + "github.com/CodeEngine/remote-bob-apiserver/internal/config" + "github.com/CodeEngine/remote-bob-apiserver/internal/log" +) + +func main() { + // Load the single configuration from environment and mounted secrets. + cfg, err := config.LoadConfig() + if err != nil { + log.Error("failed_to_load_config", err) + os.Exit(1) + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + log.Info("apiserver_starting", map[string]interface{}{ + "port": port, + "log_level": cfg.LogLevel, + }) + + // Create the server. The run token signing key is the ENCRYPTION_KEY so + // that stateless run tokens survive apiserver restarts. + srv := api.NewServer(api.Config{ + GatewayPassword: cfg.GatewayPassword, + RunTokenKey: cfg.EncryptionKey, + }) + + // Once the last agent disconnects (job succeeded/exited, idle timeout, + // or End Session), the apiserver should exit so that the container stops + // (releasing resources and port bindings) or Code Engine scales the app + // to zero. + // + // MarkShuttingDown is called from the onEmpty callback; it closes + // srv.ShutdownCh() which main selects on below. This is deliberately NOT + // done via SIGTERM because httpServer.Shutdown does not wait for hijacked + // (WebSocket) connections to close — if we sent SIGTERM the process could + // exit before the 4001 close frame reached the job-agent, leaving the job + // running indefinitely. + srv.AgentRegistry().SetOnEmpty(func() { + log.Info("last_agent_disconnected_shutting_down", nil) + srv.MarkShuttingDown() + }) + + // Setup HTTP routes. + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + + // Middleware chain: request logging (redacted) -> CORS -> panic recovery. + handler := api.NewRequestLogger(mux) + handler = api.NewCORSMiddleware().Wrap(handler) + handler = api.NewPanicRecovery(handler) + + // Create HTTP server. + // NOTE: ReadTimeout and WriteTimeout are intentionally omitted. + // Setting either on an http.Server that serves WebSocket connections causes + // the underlying TCP connection to be killed after the timeout fires. + // WS connections are long-lived; per-message deadlines are set inside the + // WS handlers instead. + httpServer := &http.Server{ + Addr: ":" + port, + Handler: handler, + IdleTimeout: 120 * time.Second, + } + + // Start server in a goroutine. + go func() { + log.Info("gateway_listening", map[string]interface{}{ + "addr": httpServer.Addr, + }) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error("server_error", err) + os.Exit(1) + } + }() + + // Wait for an OS signal or for the server to signal shutdown itself + // (last agent disconnected). Using srv.ShutdownCh() rather than SIGTERM + // ensures the process stays alive long enough for in-flight 4001 close + // frames to reach job-agents before httpServer.Shutdown runs. + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + select { + case <-quit: + case <-srv.ShutdownCh(): + // All agents have disconnected. Wait for every handleAgentWS goroutine + // to finish flushing its 4001 close frame before stopping the server. + // httpServer.Shutdown does not wait for hijacked (WebSocket) connections, + // so without this wait the job-agent would never receive 4001 and would + // keep its CE job running until the max-execution timeout. + srv.WaitAgentsDone() + } + + log.Info("gateway_shutting_down", nil) + + // Graceful shutdown with timeout. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := httpServer.Shutdown(ctx); err != nil { + log.Error("shutdown_error", err) + } + + srv.Shutdown() + + log.Info("gateway_stopped", nil) +} diff --git a/remote-bob/apiserver/go.mod b/remote-bob/apiserver/go.mod new file mode 100644 index 00000000..bc8065ef --- /dev/null +++ b/remote-bob/apiserver/go.mod @@ -0,0 +1,29 @@ +module github.com/CodeEngine/remote-bob-apiserver + +go 1.26.0 + +require ( + github.com/IBM/code-engine-go-sdk v0.0.0-20260528100318-4a3d1a978147 + github.com/IBM/go-sdk-core/v5 v5.22.1 + github.com/google/uuid v1.6.0 +) + +require ( + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/go-openapi/errors v0.22.7 // indirect + github.com/go-openapi/strfmt v0.26.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.2 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/remote-bob/apiserver/go.sum b/remote-bob/apiserver/go.sum new file mode 100644 index 00000000..64fef38e --- /dev/null +++ b/remote-bob/apiserver/go.sum @@ -0,0 +1,75 @@ +github.com/IBM/code-engine-go-sdk v0.0.0-20260528100318-4a3d1a978147 h1:q9iz7qFgDXc35ZfuIfgNZPZG0q2XII4sE9sgnwai70o= +github.com/IBM/code-engine-go-sdk v0.0.0-20260528100318-4a3d1a978147/go.mod h1:P5jZNI7Hh1d8nZQfVrlzrAwB0aio424eS09bD0J8RnI= +github.com/IBM/go-sdk-core/v5 v5.22.1 h1:5eTGq4IFEMZnb7fRdk+oxQMFvj0cRAUJqdPxojpGtY8= +github.com/IBM/go-sdk-core/v5 v5.22.1/go.mod h1:yO+OQpByKDLTvpEcsFFexgzpeR8eRfCFWAYzxkAu4bk= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= +github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= +github.com/go-openapi/strfmt v0.26.2 h1:ysjheCh4i1rmFEo2LanhELDNucNzfWTZhUDKgWWPaFM= +github.com/go-openapi/strfmt v0.26.2/go.mod h1:fXh1e449cyUn2NYuz+wb3wARBUdMl7qPEZwX00nqivY= +github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= +github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= +github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA= +github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/remote-bob/apiserver/internal/api/agent_registry.go b/remote-bob/apiserver/internal/api/agent_registry.go new file mode 100644 index 00000000..31cbe35c --- /dev/null +++ b/remote-bob/apiserver/internal/api/agent_registry.go @@ -0,0 +1,270 @@ +package api + +import ( + "sync" + "time" + + "github.com/CodeEngine/remote-bob-apiserver/internal/log" + "github.com/CodeEngine/remote-bob-apiserver/internal/ws" +) + +// Service describes a service registered by an agent. +type Service struct { + Name string `json:"name"` + Upstream string `json:"upstream"` +} + +// AgentInfo is the public view of a registered agent. +type AgentInfo struct { + AgentID string `json:"agent_id"` + Status string `json:"status"` + Services []string `json:"services"` + RegisteredAt time.Time `json:"registered_at"` +} + +// agentEntry holds a registered agent's control connection and services. +type agentEntry struct { + conn *ws.Conn + services []Service + registeredAt time.Time + // terminateCh is closed by Disconnect to signal handleAgentWS that it + // should send close code 4001 and close the connection. This avoids a + // concurrent-write race: handleAgentWS owns the read loop; the write of + // the close frame is done from a goroutine that waits on terminateCh and + // serialises the write through the relayManager's per-connection mutex. + terminateCh chan struct{} +} + +// AgentRegistry maintains the set of registered agents. It is multi-agent: +// distinct agent IDs coexist, and a second control connection with the same +// agent ID replaces the first. +type AgentRegistry struct { + mu sync.RWMutex + entries map[string]*agentEntry + // onEmpty is called (outside the lock, in a goroutine) when the last + // registered agent unregisters and the registry becomes empty. Only + // fired after at least one agent has been registered (to avoid firing + // on startup before any agent connects). May be nil. + onEmpty func() + everFull bool // true once at least one agent has been registered +} + +// NewAgentRegistry creates an empty agent registry. +func NewAgentRegistry() *AgentRegistry { + return &AgentRegistry{ + entries: make(map[string]*agentEntry), + } +} + +// SetOnEmpty registers a callback that is invoked once, in its own goroutine, +// when the registry transitions from non-empty to empty after at least one +// agent has registered. Intended for CE-mode auto-shutdown. Must be called +// before any agents register. +func (r *AgentRegistry) SetOnEmpty(fn func()) { + r.mu.Lock() + defer r.mu.Unlock() + r.onEmpty = fn +} + +// maybeFireOnEmpty fires onEmpty (in a goroutine) if the registry is now +// empty and has previously been non-empty. Must be called with r.mu held. +// Releases r.mu before dispatching the goroutine. +func (r *AgentRegistry) maybeFireOnEmpty() { + if r.everFull && len(r.entries) == 0 && r.onEmpty != nil { + fn := r.onEmpty + r.onEmpty = nil // fire at most once + r.mu.Unlock() + go fn() + return + } + r.mu.Unlock() +} + +// Register adds or replaces the control connection for an agent. A second +// connection with the same agent ID closes the previous one (replacement +// semantics). Services are reset until the agent sends a register message. +func (r *AgentRegistry) Register(agentID string, conn *ws.Conn) chan struct{} { + r.mu.Lock() + defer r.mu.Unlock() + + if existing, ok := r.entries[agentID]; ok { + log.Warn("agent_connection_replaced", map[string]interface{}{ + "agent_id": agentID, + }) + if existing.conn != nil { + existing.conn.Close() + } + } + + terminateCh := make(chan struct{}) + r.entries[agentID] = &agentEntry{ + conn: conn, + services: nil, + registeredAt: time.Now(), + terminateCh: terminateCh, + } + r.everFull = true + log.Info("agent_connection_registered", map[string]interface{}{ + "agent_id": agentID, + }) + return terminateCh +} + +// Unregister removes an agent's control connection, but only if the given +// connection is the one currently registered (a stale handler whose +// connection was replaced must not remove the new entry). +func (r *AgentRegistry) Unregister(agentID string, conn *ws.Conn) { + r.mu.Lock() + + entry, ok := r.entries[agentID] + if !ok { + r.mu.Unlock() + return + } + if entry.conn != conn { + log.Info("agent_connection_unregister_skipped_stale", map[string]interface{}{ + "agent_id": agentID, + }) + r.mu.Unlock() + return + } + if entry.conn != nil { + entry.conn.Close() + } + delete(r.entries, agentID) + log.Info("agent_connection_unregistered", map[string]interface{}{ + "agent_id": agentID, + }) + r.maybeFireOnEmpty() // releases r.mu +} + +// SetServices records the services registered by an agent and marks it ready. +func (r *AgentRegistry) SetServices(agentID string, services []Service) bool { + r.mu.Lock() + defer r.mu.Unlock() + entry, ok := r.entries[agentID] + if !ok { + return false + } + entry.services = services + return true +} + +// Get returns the control connection for an agent, or nil. +func (r *AgentRegistry) Get(agentID string) *ws.Conn { + r.mu.RLock() + defer r.mu.RUnlock() + if entry, ok := r.entries[agentID]; ok { + return entry.conn + } + return nil +} + +// Has reports whether an agent is registered. +func (r *AgentRegistry) Has(agentID string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + _, ok := r.entries[agentID] + return ok +} + +// HasService reports whether a registered agent offers the named service. +func (r *AgentRegistry) HasService(agentID, service string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + entry, ok := r.entries[agentID] + if !ok { + return false + } + for _, s := range entry.services { + if s.Name == service { + return true + } + } + return false +} + +// List returns the public view of all registered agents. +// Status is "ready" once the agent has sent its register message (at least +// one service is listed); "pending" before that. +func (r *AgentRegistry) List() []AgentInfo { + r.mu.RLock() + defer r.mu.RUnlock() + + infos := make([]AgentInfo, 0, len(r.entries)) + for id, entry := range r.entries { + services := make([]string, 0, len(entry.services)) + for _, s := range entry.services { + services = append(services, s.Name) + } + status := "pending" + if len(entry.services) > 0 { + status = "ready" + } + infos = append(infos, AgentInfo{ + AgentID: id, + Status: status, + Services: services, + RegisteredAt: entry.registeredAt, + }) + } + return infos +} + +// Count returns the number of registered agents. +func (r *AgentRegistry) Count() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.entries) +} + +// closeCodeAgentTerminated is the WS close code sent to a job-agent control +// connection when the session is deliberately terminated (End Session / DELETE +// /agents/{id}). The job-agent detects this code and shuts down instead of +// reconnecting. +const closeCodeAgentTerminated = 4001 + +// Disconnect signals the control connection for an agent to send close code +// 4001 and shut down, then removes the agent from the registry. The actual +// write of the close frame is performed by handleAgentWS (which owns the +// connection's read loop) via the terminateCh, avoiding a concurrent-write +// race. Returns true if an entry existed. +func (r *AgentRegistry) Disconnect(agentID string) bool { + r.mu.Lock() + + entry, ok := r.entries[agentID] + if !ok { + r.mu.Unlock() + return false + } + terminateCh := entry.terminateCh + delete(r.entries, agentID) + log.Info("agent_disconnected_by_request", map[string]interface{}{ + "agent_id": agentID, + }) + r.maybeFireOnEmpty() // releases r.mu + + // Signal the handleAgentWS goroutine to send close 4001 and close the + // connection. Do this after releasing the lock so we don't hold the + // registry lock while closing. + if terminateCh != nil { + close(terminateCh) + } + return true +} + +// Close closes all control connections and clears the registry. +func (r *AgentRegistry) Close() { + r.mu.Lock() + defer r.mu.Unlock() + + for agentID, entry := range r.entries { + if entry.conn != nil { + entry.conn.Close() + } + log.Info("agent_connection_closed", map[string]interface{}{ + "agent_id": agentID, + }) + } + r.entries = make(map[string]*agentEntry) +} diff --git a/remote-bob/apiserver/internal/api/agent_registry_test.go b/remote-bob/apiserver/internal/api/agent_registry_test.go new file mode 100644 index 00000000..7b5985ac --- /dev/null +++ b/remote-bob/apiserver/internal/api/agent_registry_test.go @@ -0,0 +1,333 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/CodeEngine/remote-bob-apiserver/internal/ws" +) + +// nowPlus returns a time d in the future. +func nowPlus(d time.Duration) time.Time { + return time.Now().Add(d) +} + +// timeoutChan returns a channel that fires after 2 seconds. +func timeoutChan() <-chan time.Time { + return time.After(2 * time.Second) +} + +// newTestWSConn creates a real *ws.Conn backed by an httptest server +// that reads until the connection closes. The returned conn is cleaned up +// automatically. A background goroutine drains the conn so that Done() fires +// when the server side closes the connection. +func newTestWSConn(t *testing.T) *ws.Conn { + t.Helper() + ready := make(chan struct{}) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := ws.Upgrade(w, r) + if err != nil { + return + } + close(ready) + defer conn.Close() + for { + if _, err := conn.ReadFrame(); err != nil { + return + } + } + })) + t.Cleanup(ts.Close) + + url := "ws" + strings.TrimPrefix(ts.URL, "http") + conn, err := ws.Dial(url) + if err != nil { + t.Fatalf("dial test WS: %v", err) + } + t.Cleanup(func() { conn.Close() }) + // Run a background reader so Done() fires when the server closes the TCP conn. + go func() { + for { + if _, err := conn.ReadFrame(); err != nil { + return + } + } + }() + // Wait for server side to be ready + select { + case <-ready: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for server-side WS upgrade") + } + return conn +} + +// waitConnClosed waits until the connection's Done channel fires (i.e. it is closed). +func waitConnClosed(t *testing.T, conn *ws.Conn) { + t.Helper() + select { + case <-conn.Done(): + case <-timeoutChan(): + t.Fatal("timed out waiting for connection close") + } +} + +func TestAgentRegistry_RegisterAndList(t *testing.T) { + reg := NewAgentRegistry() + conn := newTestWSConn(t) + + reg.Register("agent-1", conn) + reg.SetServices("agent-1", []Service{{Name: "ttyd", Upstream: "ws://127.0.0.1:7080"}}) + + if !reg.Has("agent-1") { + t.Fatal("agent should be registered") + } + if !reg.HasService("agent-1", "ttyd") { + t.Error("agent should offer service ttyd") + } + if reg.HasService("agent-1", "nope") { + t.Error("agent should not offer unregistered service") + } + + infos := reg.List() + if len(infos) != 1 { + t.Fatalf("expected 1 agent, got %d", len(infos)) + } + if infos[0].AgentID != "agent-1" { + t.Errorf("agent_id = %q", infos[0].AgentID) + } + if infos[0].Status != "ready" { + t.Errorf("status = %q, want ready", infos[0].Status) + } + if len(infos[0].Services) != 1 || infos[0].Services[0] != "ttyd" { + t.Errorf("services = %v, want [ttyd]", infos[0].Services) + } + if infos[0].RegisteredAt.IsZero() { + t.Error("registered_at should be set") + } +} + +func TestAgentRegistry_MultiAgent(t *testing.T) { + reg := NewAgentRegistry() + reg.Register("agent-A", newTestWSConn(t)) + reg.Register("agent-B", newTestWSConn(t)) + reg.SetServices("agent-A", []Service{{Name: "ttyd"}}) + reg.SetServices("agent-B", []Service{{Name: "ttyd"}}) + + if reg.Count() != 2 { + t.Fatalf("expected 2 agents, got %d", reg.Count()) + } + infos := reg.List() + if len(infos) != 2 { + t.Fatalf("expected 2 entries in List, got %d", len(infos)) + } +} + +func TestAgentRegistry_ReplacementClosesOldConn(t *testing.T) { + reg := NewAgentRegistry() + conn1 := newTestWSConn(t) + conn2 := newTestWSConn(t) + + reg.Register("agent-1", conn1) + reg.Register("agent-1", conn2) + + // The old connection must be closed by the registry — Done() fires. + select { + case <-conn1.Done(): + // expected + case <-time.After(2 * time.Second): + t.Error("replaced connection should be closed") + } + if reg.Count() != 1 { + t.Errorf("expected 1 entry after replacement, got %d", reg.Count()) + } +} + +func TestAgentRegistry_UnregisterOnlyCurrentConn(t *testing.T) { + reg := NewAgentRegistry() + conn1 := newTestWSConn(t) + conn2 := newTestWSConn(t) + + reg.Register("agent-1", conn1) + reg.Register("agent-1", conn2) + + // A stale handler for conn1 must not remove the new entry. + reg.Unregister("agent-1", conn1) + if !reg.Has("agent-1") { + t.Error("stale unregister must not remove the current entry") + } + + // The current handler unregisters properly. + reg.Unregister("agent-1", conn2) + if reg.Has("agent-1") { + t.Error("agent should be unregistered") + } +} + +func TestAgentRegistry_ReRegisterReplacesServices(t *testing.T) { + reg := NewAgentRegistry() + reg.Register("agent-1", newTestWSConn(t)) + reg.SetServices("agent-1", []Service{{Name: "ttyd"}}) + + if !reg.HasService("agent-1", "ttyd") { + t.Fatal("ttyd should be registered") + } + + // Re-register with a different service set: the old service is gone. + reg.SetServices("agent-1", []Service{{Name: "openclaw"}}) + if reg.HasService("agent-1", "ttyd") { + t.Error("removed service should no longer be connectable") + } + if !reg.HasService("agent-1", "openclaw") { + t.Error("newly added service should be connectable") + } +} + +func TestAgentRegistry_ConcurrentRegister(t *testing.T) { + reg := NewAgentRegistry() + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + reg.Register("agent-"+string(rune('A'+i%5)), newTestWSConn(t)) + }(i) + } + wg.Wait() + + if reg.Count() != 5 { + t.Errorf("expected 5 distinct agents, got %d", reg.Count()) + } +} + +func TestAgentRegistry_CloseClosesAll(t *testing.T) { + reg := NewAgentRegistry() + conn1 := newTestWSConn(t) + conn2 := newTestWSConn(t) + reg.Register("agent-1", conn1) + reg.Register("agent-2", conn2) + + reg.Close() + + select { + case <-conn1.Done(): + case <-time.After(2 * time.Second): + t.Error("Close should close conn1") + } + select { + case <-conn2.Done(): + case <-time.After(2 * time.Second): + t.Error("Close should close conn2") + } + if reg.Count() != 0 { + t.Errorf("expected 0 after Close, got %d", reg.Count()) + } +} + +func TestAgentRegistry_GetReturnsConn(t *testing.T) { + reg := NewAgentRegistry() + conn := newTestWSConn(t) + reg.Register("agent-1", conn) + + if reg.Get("agent-1") != conn { + t.Error("Get should return the registered connection") + } + if reg.Get("missing") != nil { + t.Error("Get should return nil for unknown agent") + } +} + +func TestAgentRegistry_OnEmpty_FiredOnUnregister(t *testing.T) { + reg := NewAgentRegistry() + fired := make(chan struct{}, 1) + reg.SetOnEmpty(func() { fired <- struct{}{} }) + + conn := newTestWSConn(t) + reg.Register("agent-1", conn) + reg.Unregister("agent-1", conn) + + select { + case <-fired: + case <-timeoutChan(): + t.Fatal("onEmpty not called after last agent unregistered") + } +} + +func TestAgentRegistry_OnEmpty_FiredOnDisconnect(t *testing.T) { + reg := NewAgentRegistry() + fired := make(chan struct{}, 1) + reg.SetOnEmpty(func() { fired <- struct{}{} }) + + conn := newTestWSConn(t) + reg.Register("agent-1", conn) + reg.Disconnect("agent-1") + + select { + case <-fired: + case <-timeoutChan(): + t.Fatal("onEmpty not called after Disconnect") + } +} + +func TestAgentRegistry_OnEmpty_NotFiredBeforeAnyAgent(t *testing.T) { + reg := NewAgentRegistry() + fired := make(chan struct{}, 1) + reg.SetOnEmpty(func() { fired <- struct{}{} }) + + // No agents ever registered — callback must never fire. + select { + case <-fired: + t.Fatal("onEmpty must not fire when no agent ever registered") + case <-time.After(100 * time.Millisecond): + } +} + +func TestAgentRegistry_OnEmpty_NotFiredWhenAgentsRemain(t *testing.T) { + reg := NewAgentRegistry() + fired := make(chan struct{}, 1) + reg.SetOnEmpty(func() { fired <- struct{}{} }) + + conn1 := newTestWSConn(t) + conn2 := newTestWSConn(t) + reg.Register("agent-1", conn1) + reg.Register("agent-2", conn2) + reg.Unregister("agent-1", conn1) // one still registered + + select { + case <-fired: + t.Fatal("onEmpty must not fire while agent-2 is still registered") + case <-time.After(100 * time.Millisecond): + } +} + +func TestAgentRegistry_OnEmpty_FiredOnceOnly(t *testing.T) { + reg := NewAgentRegistry() + count := 0 + var mu sync.Mutex + reg.SetOnEmpty(func() { + mu.Lock() + count++ + mu.Unlock() + }) + + conn := newTestWSConn(t) + reg.Register("agent-1", conn) + reg.Unregister("agent-1", conn) + time.Sleep(50 * time.Millisecond) + + // Register and unregister a second agent — callback must not fire again. + conn2 := newTestWSConn(t) + reg.Register("agent-2", conn2) + reg.Unregister("agent-2", conn2) + time.Sleep(50 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + if count != 1 { + t.Errorf("onEmpty fired %d times, want exactly 1", count) + } +} diff --git a/remote-bob/apiserver/internal/api/basic_auth.go b/remote-bob/apiserver/internal/api/basic_auth.go new file mode 100644 index 00000000..9b0fd5cb --- /dev/null +++ b/remote-bob/apiserver/internal/api/basic_auth.go @@ -0,0 +1,56 @@ +package api + +import ( + "crypto/subtle" + "net/http" +) + +const ( + gatewayUsername = "admin" + authRealm = "Remote Bob Gateway" +) + +// BasicAuth validates HTTP Basic credentials against GATEWAY_PASSWORD. It +// fails closed: when no password is configured, every request is rejected +// with 503 rather than being allowed through. +type BasicAuth struct { + password string +} + +// NewBasicAuth creates a BasicAuth checker. +func NewBasicAuth(password string) *BasicAuth { + return &BasicAuth{password: password} +} + +// Check validates the request's Basic credentials. On failure it writes the +// error response and returns false. Wrong username and wrong password produce +// identical responses to prevent user enumeration. +func (a *BasicAuth) Check(w http.ResponseWriter, r *http.Request) bool { + if a.password == "" { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "authentication_not_configured", + "message": "server authentication is not configured", + }) + return false + } + username, password, ok := r.BasicAuth() + if !ok { + a.unauthorized(w) + return false + } + userOK := subtle.ConstantTimeCompare([]byte(username), []byte(gatewayUsername)) == 1 + passOK := subtle.ConstantTimeCompare([]byte(password), []byte(a.password)) == 1 + if !userOK || !passOK { + a.unauthorized(w) + return false + } + return true +} + +func (a *BasicAuth) unauthorized(w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", `Basic realm="`+authRealm+`"`) + writeJSON(w, http.StatusUnauthorized, map[string]string{ + "error": "authentication_failed", + "message": "invalid credentials", + }) +} diff --git a/remote-bob/apiserver/internal/api/basic_auth_test.go b/remote-bob/apiserver/internal/api/basic_auth_test.go new file mode 100644 index 00000000..cb369e50 --- /dev/null +++ b/remote-bob/apiserver/internal/api/basic_auth_test.go @@ -0,0 +1,130 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestBasicAuth_ValidCredentials(t *testing.T) { + auth := NewBasicAuth("secret-password") + req := httptest.NewRequest(http.MethodPost, "/auth/login", nil) + req.SetBasicAuth("admin", "secret-password") + w := httptest.NewRecorder() + + if !auth.Check(w, req) { + t.Fatal("Check() should accept valid credentials") + } + if w.Code != http.StatusOK { + t.Errorf("no response should be written on success, got status %d", w.Code) + } +} + +func TestBasicAuth_WrongPassword(t *testing.T) { + auth := NewBasicAuth("secret-password") + req := httptest.NewRequest(http.MethodPost, "/auth/login", nil) + req.SetBasicAuth("admin", "wrong-password") + w := httptest.NewRecorder() + + if auth.Check(w, req) { + t.Fatal("Check() should reject a wrong password") + } + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } + if w.Header().Get("WWW-Authenticate") == "" { + t.Error("401 response should include WWW-Authenticate") + } +} + +func TestBasicAuth_WrongUsername(t *testing.T) { + auth := NewBasicAuth("secret-password") + req := httptest.NewRequest(http.MethodPost, "/auth/login", nil) + req.SetBasicAuth("attacker", "secret-password") + w := httptest.NewRecorder() + + if auth.Check(w, req) { + t.Fatal("Check() should reject a wrong username") + } + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestBasicAuth_MissingCredentials(t *testing.T) { + auth := NewBasicAuth("secret-password") + req := httptest.NewRequest(http.MethodPost, "/auth/login", nil) + w := httptest.NewRecorder() + + if auth.Check(w, req) { + t.Fatal("Check() should reject missing credentials") + } + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestBasicAuth_MalformedHeader(t *testing.T) { + auth := NewBasicAuth("secret-password") + req := httptest.NewRequest(http.MethodPost, "/auth/login", nil) + // Malformed: not a valid Basic header. + req.Header.Set("Authorization", "Basic not-base64!!!") + w := httptest.NewRecorder() + + if auth.Check(w, req) { + t.Fatal("Check() should reject a malformed Authorization header") + } + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestBasicAuth_WrongUserAndWrongPassIndistinguishable(t *testing.T) { + auth := NewBasicAuth("secret-password") + + wrongUser := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/auth/login", nil) + req.SetBasicAuth("attacker", "secret-password") + auth.Check(wrongUser, req) + + wrongPass := httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/auth/login", nil) + req.SetBasicAuth("admin", "wrong-password") + auth.Check(wrongPass, req) + + if wrongUser.Code != wrongPass.Code { + t.Errorf("status codes differ: %d vs %d", wrongUser.Code, wrongPass.Code) + } + if wrongUser.Body.String() != wrongPass.Body.String() { + t.Errorf("bodies differ: %q vs %q", wrongUser.Body.String(), wrongPass.Body.String()) + } +} + +func TestBasicAuth_FailsClosedWhenUnset(t *testing.T) { + auth := NewBasicAuth("") + req := httptest.NewRequest(http.MethodPost, "/auth/login", nil) + req.SetBasicAuth("admin", "anything") + w := httptest.NewRecorder() + + if auth.Check(w, req) { + t.Fatal("Check() must fail closed when no password is configured") + } + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", w.Code) + } +} + +func TestBasicAuth_FailsClosedWhenEmptyPassword(t *testing.T) { + // An empty-string GATEWAY_PASSWORD is treated as unset. + auth := NewBasicAuth("") + req := httptest.NewRequest(http.MethodPost, "/auth/login", nil) + req.SetBasicAuth("admin", "") + w := httptest.NewRecorder() + + if auth.Check(w, req) { + t.Fatal("Check() must reject an empty password when GATEWAY_PASSWORD is empty") + } + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", w.Code) + } +} diff --git a/remote-bob/apiserver/internal/api/cors.go b/remote-bob/apiserver/internal/api/cors.go new file mode 100644 index 00000000..6f3e4191 --- /dev/null +++ b/remote-bob/apiserver/internal/api/cors.go @@ -0,0 +1,81 @@ +package api + +import ( + "net/http" + "strings" +) + +// CORSMiddleware handles Cross-Origin Resource Sharing. Allowed origins are +// configurable via ALLOWED_ORIGINS (comma-separated); the default is the +// local development origins. Preflight (OPTIONS) requests are answered +// without authentication so the browser login flow works cross-origin. +type CORSMiddleware struct { + allowedOrigins []string +} + +// NewCORSMiddleware creates a CORS middleware from the environment. +func NewCORSMiddleware() *CORSMiddleware { + return NewCORSMiddlewareWithOrigins(allowedOriginsFromEnv()) +} + +// NewCORSMiddlewareWithOrigins creates a CORS middleware with explicit origins. +func NewCORSMiddlewareWithOrigins(origins []string) *CORSMiddleware { + return &CORSMiddleware{allowedOrigins: origins} +} + +// AllowedOrigins returns the configured allowed origins. +func (m *CORSMiddleware) AllowedOrigins() []string { + return m.allowedOrigins +} + +// Wrap wraps an HTTP handler with CORS headers and preflight handling. +func (m *CORSMiddleware) Wrap(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" && m.originAllowed(origin) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Max-Age", "86400") + } + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) +} + +func (m *CORSMiddleware) originAllowed(origin string) bool { + for _, allowed := range m.allowedOrigins { + if allowed == "*" || origin == allowed { + return true + } + } + return false +} + +func allowedOriginsFromEnv() []string { + originsEnv := getEnv("ALLOWED_ORIGINS") + if originsEnv == "" { + return []string{ + "http://localhost:3000", + "http://127.0.0.1:3000", + // The static browser client is loaded from file://, whose Origin + // header is the literal string "null". Allow it so the login + // fetch and WS connect work from a file:// page (VAL-BROWSER-048). + "null", + } + } + parts := strings.Split(originsEnv, ",") + origins := make([]string, 0, len(parts)) + for _, p := range parts { + if trimmed := strings.TrimSpace(p); trimmed != "" { + origins = append(origins, trimmed) + } + } + return origins +} diff --git a/remote-bob/apiserver/internal/api/cors_test.go b/remote-bob/apiserver/internal/api/cors_test.go new file mode 100644 index 00000000..bd637e69 --- /dev/null +++ b/remote-bob/apiserver/internal/api/cors_test.go @@ -0,0 +1,129 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCORS_AllowsConfiguredOrigin(t *testing.T) { + mw := NewCORSMiddlewareWithOrigins([]string{"http://localhost:3000"}) + handler := mw.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + req.Header.Set("Origin", "http://localhost:3000") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:3000" { + t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, "http://localhost:3000") + } + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestCORS_RejectsDisallowedOrigin(t *testing.T) { + mw := NewCORSMiddlewareWithOrigins([]string{"http://localhost:3000"}) + handler := mw.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + req.Header.Set("Origin", "http://evil.example.com") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("Access-Control-Allow-Origin = %q, want empty for disallowed origin", got) + } +} + +func TestCORS_PreflightSucceedsWithoutAuth(t *testing.T) { + mw := NewCORSMiddlewareWithOrigins([]string{"http://localhost:3000"}) + handler := mw.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("next handler must not be called for preflight") + })) + + req := httptest.NewRequest(http.MethodOptions, "/auth/login", nil) + req.Header.Set("Origin", "http://localhost:3000") + req.Header.Set("Access-Control-Request-Method", "POST") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("preflight should return 2xx, got %d", w.Code) + } + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:3000" { + t.Errorf("preflight Access-Control-Allow-Origin = %q", got) + } +} + +func TestCORS_AllowHeadersIncludesAuthorizationAndContentType(t *testing.T) { + mw := NewCORSMiddlewareWithOrigins([]string{"http://localhost:3000"}) + handler := mw.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodOptions, "/auth/login", nil) + req.Header.Set("Origin", "http://localhost:3000") + req.Header.Set("Access-Control-Request-Method", "POST") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + allowHeaders := w.Header().Get("Access-Control-Allow-Headers") + if !strings.Contains(allowHeaders, "Authorization") { + t.Errorf("Access-Control-Allow-Headers = %q, want it to include Authorization", allowHeaders) + } + if !strings.Contains(allowHeaders, "Content-Type") { + t.Errorf("Access-Control-Allow-Headers = %q, want it to include Content-Type", allowHeaders) + } +} + +func TestCORS_DefaultOrigins(t *testing.T) { + mw := NewCORSMiddlewareWithOrigins(allowedOriginsFromEnv()) + if len(mw.AllowedOrigins()) == 0 { + t.Fatal("default CORS middleware should have at least one allowed origin") + } +} + +func TestCORS_AllowsNullOrigin(t *testing.T) { + // The static browser client is loaded from file://, whose Origin header + // is the literal string "null". The login fetch and WS connect must work + // from a file:// page (VAL-BROWSER-048). + mw := NewCORSMiddlewareWithOrigins(allowedOriginsFromEnv()) + handler := mw.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodPost, "/auth/login", nil) + req.Header.Set("Origin", "null") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "null" { + t.Errorf("Access-Control-Allow-Origin = %q, want %q for file:// (null) origin", got, "null") + } + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestCORS_WildcardAllowsAnyOrigin(t *testing.T) { + mw := NewCORSMiddlewareWithOrigins([]string{"*"}) + handler := mw.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + req.Header.Set("Origin", "http://anything.example.com") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://anything.example.com" { + t.Errorf("Access-Control-Allow-Origin = %q, want the request origin", got) + } +} diff --git a/remote-bob/apiserver/internal/api/end_session_test.go b/remote-bob/apiserver/internal/api/end_session_test.go new file mode 100644 index 00000000..975eb636 --- /dev/null +++ b/remote-bob/apiserver/internal/api/end_session_test.go @@ -0,0 +1,661 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/CodeEngine/remote-bob-apiserver/internal/ws" +) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// deleteAgent issues an authenticated DELETE /agents/{id} and returns the +// HTTP response. +func deleteAgent(t *testing.T, ts, agentID string) *http.Response { + t.Helper() + return doRequest(t, http.MethodDelete, ts+"/agents/"+agentID, map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) +} + +// agentReceivedClose blocks until the agent WS connection receives the given +// WS close code or times out. +func agentReceivedClose(t *testing.T, conn *ws.Conn, wantCode int) { + t.Helper() + // Give the server up to 3 s to deliver the close frame. + done := make(chan struct{}) + go func() { + defer close(done) + conn.ReadFrame() //nolint:errcheck — we expect an error (close frame) + }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for close frame from server") + } + // CloseCode() is populated by ReadFrame when it processes a MsgClose frame. + if code := conn.CloseCode(); code != wantCode { + t.Errorf("close code = %d, want %d", code, wantCode) + } +} + +// --------------------------------------------------------------------------- +// DELETE /agents/{id} — HTTP contract +// --------------------------------------------------------------------------- + +func TestEndSession_DeleteReturns204(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + runToken := issueRunToken(t, ts, "agent-1") + agentConn := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer agentConn.Close() + waitAgentReady(t, ts, "agent-1") + + resp := deleteAgent(t, ts, "agent-1") + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Errorf("DELETE /agents/agent-1 = %d, want 204", resp.StatusCode) + } +} + +func TestEndSession_DeleteUnknownAgentReturns404AndStaysShutdown(t *testing.T) { + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + _ = wsBase + + resp := deleteAgent(t, ts, "no-such-agent") + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("DELETE unknown agent = %d, want 404", resp.StatusCode) + } + // Even on a 404, the server must be marked shutting down. + if srv.shuttingDown.Load() == 0 { + t.Error("server must be marked shutting_down even when agent not found") + } +} + +func TestEndSession_DeleteRequiresAuth(t *testing.T) { + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + runToken := issueRunToken(t, ts, "agent-1") + agentConn := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer agentConn.Close() + waitAgentReady(t, ts, "agent-1") + + // No auth. + resp := doRequest(t, http.MethodDelete, ts+"/agents/agent-1", nil) + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("DELETE without auth = %d, want 401", resp.StatusCode) + } + + // Wrong password. + resp = doRequest(t, http.MethodDelete, ts+"/agents/agent-1", map[string]string{ + "Authorization": basicHeader("admin", "wrong"), + }) + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("DELETE wrong auth = %d, want 401", resp.StatusCode) + } + + // Agent must still be alive. + if !srv.AgentRegistry().Has("agent-1") { + t.Error("agent must not be disconnected by unauthenticated DELETE") + } +} + +// --------------------------------------------------------------------------- +// 4001 close frame delivery to job-agent +// --------------------------------------------------------------------------- + +func TestEndSession_Delete_Sends4001ToAgent(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + runToken := issueRunToken(t, ts, "agent-1") + agentConn := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer agentConn.Close() + waitAgentReady(t, ts, "agent-1") + + resp := deleteAgent(t, ts, "agent-1") + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("DELETE = %d, want 204", resp.StatusCode) + } + + // The agent control connection must receive WS close 4001. + agentReceivedClose(t, agentConn, closeCodeAgentTerminated) +} + +func TestEndSession_Delete_Sends4001_AgentDropsAfterClose(t *testing.T) { + // Full lifecycle: agent connects, DELETE is issued, agent gets 4001 and + // its read loop exits, Unregister is called, registry is empty. + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + runToken := issueRunToken(t, ts, "agent-1") + agentConn := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer agentConn.Close() + waitAgentReady(t, ts, "agent-1") + + resp := deleteAgent(t, ts, "agent-1") + resp.Body.Close() + + // Agent receives 4001 and closes its connection (simulating job-agent + // shutting down after errTerminated). + agentReceivedClose(t, agentConn, closeCodeAgentTerminated) + agentConn.Close() + + // Registry must empty out. + deadline := time.Now().Add(2 * time.Second) + for srv.AgentRegistry().Has("agent-1") && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + if srv.AgentRegistry().Has("agent-1") { + t.Error("agent must be unregistered after control connection closes") + } + if srv.AgentRegistry().Count() != 0 { + t.Errorf("registry count = %d, want 0", srv.AgentRegistry().Count()) + } +} + +func TestEndSession_Delete_4001SentBeforeWaitAgentsDoneReturns(t *testing.T) { + // This is the regression test for the original bug: WaitAgentsDone must + // not return until the 4001 close frame has been sent. + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + runToken := issueRunToken(t, ts, "agent-1") + + // The "agent" is a goroutine that records the close code it receives. + receivedClose := make(chan int, 1) + agentConn := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + go func() { + defer agentConn.Close() + _, err := agentConn.ReadFrame() + if err != nil && ws.IsCloseError(agentConn, err, closeCodeAgentTerminated) { + receivedClose <- agentConn.CloseCode() + } else if err == io.EOF { + receivedClose <- agentConn.CloseCode() + } else { + receivedClose <- -1 + } + }() + waitAgentReady(t, ts, "agent-1") + + resp := deleteAgent(t, ts, "agent-1") + resp.Body.Close() + + // WaitAgentsDone must block until the handleAgentWS goroutine has returned + // (meaning the 4001 frame was sent and conn.Close() was called). + done := make(chan struct{}) + go func() { + srv.WaitAgentsDone() + close(done) + }() + + select { + case <-done: + // Good — WaitAgentsDone returned. + case <-time.After(3 * time.Second): + t.Fatal("WaitAgentsDone did not return within 3s after DELETE") + } + + // The close code the agent received must be 4001. + select { + case code := <-receivedClose: + if code != closeCodeAgentTerminated { + t.Errorf("agent received close code %d, want %d", code, closeCodeAgentTerminated) + } + case <-time.After(time.Second): + t.Error("agent never received a close frame") + } +} + +// --------------------------------------------------------------------------- +// ShutdownCh behaviour +// --------------------------------------------------------------------------- + +func TestEndSession_ShutdownChClosedByDelete(t *testing.T) { + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + runToken := issueRunToken(t, ts, "agent-1") + agentConn := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer agentConn.Close() + waitAgentReady(t, ts, "agent-1") + + // ShutdownCh must be open before DELETE. + select { + case <-srv.ShutdownCh(): + t.Fatal("ShutdownCh must be open before DELETE is called") + default: + } + + resp := deleteAgent(t, ts, "agent-1") + resp.Body.Close() + + select { + case <-srv.ShutdownCh(): + // Closed immediately after DELETE. + case <-time.After(2 * time.Second): + t.Fatal("ShutdownCh was not closed within 2s after DELETE /agents/agent-1") + } +} + +func TestEndSession_ShutdownChClosedByIdleDisconnect(t *testing.T) { + // Simulates idle timeout: agent control connection drops naturally → + // onEmpty → MarkShuttingDown → ShutdownCh closed. + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + _ = ts + + fired := make(chan struct{}) + srv.AgentRegistry().SetOnEmpty(func() { + srv.MarkShuttingDown() + close(fired) + }) + + runToken := issueRunToken(t, ts, "agent-1") + agentConn := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + + // Closing the connection simulates idle-timeout agent exit. + agentConn.Close() + + select { + case <-fired: + case <-time.After(2 * time.Second): + t.Fatal("onEmpty callback not fired within 2s after connection close") + } + + select { + case <-srv.ShutdownCh(): + // Correct. + case <-time.After(time.Second): + t.Fatal("ShutdownCh not closed after MarkShuttingDown from onEmpty") + } +} + +func TestEndSession_MarkShuttingDown_Idempotent(t *testing.T) { + srv, _ := newWSServer(t, "test-password", testRunKey()) + + // Multiple calls must not panic (shutdownOnce.Do guarantees this). + for i := 0; i < 10; i++ { + srv.MarkShuttingDown() + } + + // Channel must be closed (readable) after any call. + select { + case <-srv.ShutdownCh(): + default: + t.Error("ShutdownCh must be closed after MarkShuttingDown") + } +} + +func TestEndSession_ShutdownCh_OpenOnFreshServer(t *testing.T) { + srv, _ := newWSServer(t, "test-password", testRunKey()) + + select { + case <-srv.ShutdownCh(): + t.Error("ShutdownCh must be open on a fresh server") + default: + // Correct. + } +} + +// --------------------------------------------------------------------------- +// Login / browser-WS blocked after End Session +// --------------------------------------------------------------------------- + +func TestEndSession_LoginBlockedAfterDelete(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + runToken := issueRunToken(t, ts, "agent-1") + agentConn := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer agentConn.Close() + waitAgentReady(t, ts, "agent-1") + + resp := deleteAgent(t, ts, "agent-1") + resp.Body.Close() + + // Login after DELETE must return 503 shutting_down. + loginResp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + defer loginResp.Body.Close() + if loginResp.StatusCode != http.StatusServiceUnavailable { + t.Errorf("login after DELETE = %d, want 503", loginResp.StatusCode) + } + var body map[string]interface{} + if err := json.NewDecoder(loginResp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body["error"] != "shutting_down" { + t.Errorf("error = %v, want shutting_down", body["error"]) + } +} + +func TestEndSession_BrowserWSBlockedAfterDelete(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + // Get a WS token before DELETE. + token := issueWSToken(t, ts) + + runToken := issueRunToken(t, ts, "agent-1") + agentConn := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer agentConn.Close() + waitAgentReady(t, ts, "agent-1") + + resp := deleteAgent(t, ts, "agent-1") + resp.Body.Close() + + // Even with a previously-issued token, /ws/browser must be rejected. + _, dialResp, err := dialWS(t, wsBase+"/ws/browser?token="+token+"&agent=agent-1&service=ttyd", nil) + if err == nil { + t.Fatal("browser WS must be rejected after DELETE") + } + if dialResp == nil || dialResp.StatusCode != http.StatusServiceUnavailable { + t.Errorf("browser WS after DELETE = %v, want 503", dialResp) + } +} + +func TestEndSession_GetAgentByID_Returns503AfterDelete(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + runToken := issueRunToken(t, ts, "agent-1") + agentConn := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer agentConn.Close() + waitAgentReady(t, ts, "agent-1") + + resp := deleteAgent(t, ts, "agent-1") + resp.Body.Close() + + // Probe endpoint must return 503, not 200 or 404. + probe := doRequest(t, http.MethodGet, ts+"/agents/agent-1", nil) + defer probe.Body.Close() + if probe.StatusCode != http.StatusServiceUnavailable { + t.Errorf("GET /agents/agent-1 after DELETE = %d, want 503", probe.StatusCode) + } +} + +// --------------------------------------------------------------------------- +// agentWG — concurrent handler tracking +// --------------------------------------------------------------------------- + +func TestEndSession_WaitAgentsDone_NoAgentsReturnsImmediately(t *testing.T) { + srv, _ := newWSServer(t, "test-password", testRunKey()) + + done := make(chan struct{}) + go func() { + srv.WaitAgentsDone() + close(done) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("WaitAgentsDone should return immediately when no agents are connected") + } +} + +func TestEndSession_WaitAgentsDone_BlocksUntilAllHandlersExit(t *testing.T) { + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + const n = 3 + conns := make([]*ws.Conn, n) + for i := 0; i < n; i++ { + agentID := strings.Repeat("a", i+1) // "a", "aa", "aaa" + runToken := issueRunToken(t, ts, agentID) + conns[i] = registerTestAgent(t, wsBase, runToken, agentID, []Service{{Name: "ttyd"}}) + waitAgentReady(t, ts, agentID) + } + + // WaitAgentsDone must block while all agents are still connected. + waitDone := make(chan struct{}) + go func() { + srv.WaitAgentsDone() + close(waitDone) + }() + + select { + case <-waitDone: + t.Fatal("WaitAgentsDone must block while agents are still connected") + case <-time.After(100 * time.Millisecond): + // Still blocking — correct. + } + + // Close all connections; WaitAgentsDone must unblock. + for _, c := range conns { + c.Close() + } + select { + case <-waitDone: + // Correct. + case <-time.After(3 * time.Second): + t.Fatal("WaitAgentsDone did not unblock within 3s after all connections closed") + } +} + +// --------------------------------------------------------------------------- +// Full End Session relay teardown — browser gets close code 4000 +// --------------------------------------------------------------------------- + +func TestEndSession_BrowserReceivesClose4000(t *testing.T) { + // The full End Session path: + // browser connected → DELETE /agents/{id} → + // agent receives 4001 → agent shuts down relay → + // browser receives 4000 (closeCodeAgentGone from relayToBrowser). + // + // Note: in this test the "agent" is our test code, not the real job-agent + // daemon. We manually simulate what the daemon does when it receives 4001: + // close the relay WS connection (which causes relayToBrowser to send 4000 + // to the browser). + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + // Use registerTestAgent (not startTestAgent / setupRelay) so no competing + // goroutine reads from agentConn — we need to read the 4001 frame ourselves. + runToken := issueRunToken(t, ts, "agent-1") + agentConn := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer agentConn.Close() + + // Drive the agent control connection: forward control messages to a channel + // so establishRelay can consume the relay-open message. + msgs := make(chan agentControlMsg, 32) + go func() { + for { + f, err := agentConn.ReadFrame() + if err != nil { + return + } + var ctrl struct { + Type string `json:"type"` + RelayID string `json:"relay_id"` + Service string `json:"service"` + RelayToken string `json:"relay_token"` + } + if err := json.Unmarshal(f.Payload, &ctrl); err != nil { + continue + } + msgs <- agentControlMsg{ + Type: ctrl.Type, + RelayID: ctrl.RelayID, + Service: ctrl.Service, + RelayToken: ctrl.RelayToken, + } + } + }() + + waitAgentReady(t, ts, "agent-1") + + // Establish a live relay so the browser is actively connected. + browser, open := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs) + defer browser.Close() + _ = open + + // Confirm the relay is live with a round-trip. + if err := browser.WriteFrame(ws.MsgText, []byte("ping")); err != nil { + t.Fatalf("pre-DELETE write: %v", err) + } + f, err := browser.ReadFrame() + if err != nil || string(f.Payload) != "ping" { + t.Fatalf("pre-DELETE round-trip: err=%v payload=%q", err, f.Payload) + } + + // DELETE the agent — this marks shuttingDown and sends 4001 to agentConn. + resp := deleteAgent(t, ts, "agent-1") + resp.Body.Close() + + // The browser must receive close code 4000 (agent gone). relayToBrowser + // sends the close frame when the relay (agent) side disconnects. + done := make(chan struct{}) + go func() { + defer close(done) + browser.ReadFrame() //nolint:errcheck — expecting a close frame + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("browser did not receive close frame within 5s") + } + if code := browser.CloseCode(); code != closeCodeAgentGone { + t.Errorf("browser close code = %d, want %d (closeCodeAgentGone)", code, closeCodeAgentGone) + } +} + +func TestEndSession_BrowserReceivesClose4000_NoBrowserConnected(t *testing.T) { + // DELETE with no active browser relay must still deliver 4001 to the agent + // and ShutdownCh must close. No browser, so no 4000 to check. + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + runToken := issueRunToken(t, ts, "agent-1") + agentConn := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer agentConn.Close() + waitAgentReady(t, ts, "agent-1") + + // No browser relay established. + resp := deleteAgent(t, ts, "agent-1") + resp.Body.Close() + + // Agent gets 4001. + agentReceivedClose(t, agentConn, closeCodeAgentTerminated) + + // ShutdownCh is closed. + select { + case <-srv.ShutdownCh(): + case <-time.After(2 * time.Second): + t.Fatal("ShutdownCh not closed after DELETE with no active relay") + } +} + +// --------------------------------------------------------------------------- +// Concurrent End Session safety +// --------------------------------------------------------------------------- + +func TestEndSession_ConcurrentDeleteIdempotent(t *testing.T) { + // Multiple concurrent DELETE requests for the same agent must not panic, + // must not double-close terminateCh, and the agent must receive exactly + // one 4001 close frame. + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + runToken := issueRunToken(t, ts, "agent-1") + agentConn := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer agentConn.Close() + waitAgentReady(t, ts, "agent-1") + + const n = 5 + var wg sync.WaitGroup + statuses := make([]int, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + resp := deleteAgent(t, ts, "agent-1") + resp.Body.Close() + statuses[i] = resp.StatusCode + }(i) + } + wg.Wait() + + // Exactly one 204, the rest 404 (or all mixed — the critical thing is no + // panic, no double-close, and server marked shutting down). + found204 := 0 + for _, s := range statuses { + if s == http.StatusNoContent { + found204++ + } + if s != http.StatusNoContent && s != http.StatusNotFound { + t.Errorf("unexpected status %d (want 204 or 404)", s) + } + } + if found204 != 1 { + t.Errorf("exactly one DELETE should succeed with 204, got %d", found204) + } + + // Server is in shutting-down state. + if srv.shuttingDown.Load() == 0 { + t.Error("server must be in shutting-down state after DELETE") + } + + // ShutdownCh is closed. + select { + case <-srv.ShutdownCh(): + case <-time.After(2 * time.Second): + t.Fatal("ShutdownCh not closed after concurrent DELETEs") + } + + // Agent receives exactly one 4001 frame (not a panic from double-close). + agentReceivedClose(t, agentConn, closeCodeAgentTerminated) +} + +func TestEndSession_MultipleAgents_AllGet4001(t *testing.T) { + // When multiple agents are registered and the last one is deleted, + // each deleted agent gets its 4001 close frame. + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + agentIDs := []string{"alpha", "beta", "gamma"} + conns := make(map[string]*ws.Conn) + for _, id := range agentIDs { + runToken := issueRunToken(t, ts, id) + conns[id] = registerTestAgent(t, wsBase, runToken, id, []Service{{Name: "ttyd"}}) + waitAgentReady(t, ts, id) + } + defer func() { + for _, c := range conns { + c.Close() + } + }() + + // Delete all agents; each should receive 4001. + for _, id := range agentIDs { + resp := deleteAgent(t, ts, id) + resp.Body.Close() + agentReceivedClose(t, conns[id], closeCodeAgentTerminated) + } + + // After all agents are gone, ShutdownCh must be closed. + select { + case <-srv.ShutdownCh(): + case <-time.After(2 * time.Second): + t.Fatal("ShutdownCh not closed after all agents deleted") + } + if srv.AgentRegistry().Count() != 0 { + t.Errorf("registry count = %d after all agents deleted, want 0", srv.AgentRegistry().Count()) + } +} diff --git a/remote-bob/apiserver/internal/api/env.go b/remote-bob/apiserver/internal/api/env.go new file mode 100644 index 00000000..38c4243e --- /dev/null +++ b/remote-bob/apiserver/internal/api/env.go @@ -0,0 +1,6 @@ +package api + +import "os" + +// getEnv reads an environment variable (indirection for tests). +var getEnv = os.Getenv diff --git a/remote-bob/apiserver/internal/api/panic_recovery.go b/remote-bob/apiserver/internal/api/panic_recovery.go new file mode 100644 index 00000000..e9ff0113 --- /dev/null +++ b/remote-bob/apiserver/internal/api/panic_recovery.go @@ -0,0 +1,31 @@ +package api + +import ( + "net/http" + + "github.com/CodeEngine/remote-bob-apiserver/internal/log" +) + +// panicRecovery wraps a handler so that a panic in any handler returns a 500 +// instead of crashing the server. +type panicRecovery struct { + next http.Handler +} + +// NewPanicRecovery wraps a handler with panic recovery. +func NewPanicRecovery(next http.Handler) http.Handler { + return &panicRecovery{next: next} +} + +func (p *panicRecovery) ServeHTTP(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + log.Error("http_handler_panic", nil, + "method", r.Method, + "path", r.URL.Path, + "panic", rec) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + }() + p.next.ServeHTTP(w, r) +} diff --git a/remote-bob/apiserver/internal/api/relay.go b/remote-bob/apiserver/internal/api/relay.go new file mode 100644 index 00000000..b34bc579 --- /dev/null +++ b/remote-bob/apiserver/internal/api/relay.go @@ -0,0 +1,240 @@ +package api + +import ( + "encoding/json" + "errors" + "sync" + + "github.com/CodeEngine/remote-bob-apiserver/internal/ws" +) + +// relayEntry tracks a single browser↔agent relay. Every browser connection +// gets its own relay with a distinct relay_id and a one-time relay token. +type relayEntry struct { + id string + agentID string + service string + token string + controlConn *ws.Conn // control connection the relay-open was sent on + browser *ws.Conn + relayMu sync.Mutex + relay *ws.Conn // agent-side relay connection + attached chan struct{} // closed when the agent dials /ws/relay + done chan struct{} // closed when the relay is torn down + closeOnce sync.Once + // pipesStarted is set to true by startRelayPipes so teardown knows + // whether to delegate browser cleanup to relayToBrowser or do it + // directly (unit-test code paths that skip startRelayPipes). + pipesStarted bool +} + +// relayManager tracks all active relays, keyed by relay_id and by relay +// token (for the agent's /ws/relay dial). It also serializes control-message +// writes per control connection to ensure only one concurrent writer per conn. +type relayManager struct { + mu sync.Mutex + relays map[string]*relayEntry // relay_id -> entry + tokens map[string]string // relay token -> relay_id + connMu map[*ws.Conn]*sync.Mutex + revokeToken func(string) // invalidates a relay token on teardown +} + +func newRelayManager(revokeToken func(string)) *relayManager { + return &relayManager{ + relays: make(map[string]*relayEntry), + tokens: make(map[string]string), + connMu: make(map[*ws.Conn]*sync.Mutex), + revokeToken: revokeToken, + } +} + +// create registers a new relay for a browser connection and returns the +// entry. The relay token is issued by the caller. +func (m *relayManager) create(agentID, service string, browser *ws.Conn, token string, controlConn *ws.Conn) (*relayEntry, error) { + id, err := randomToken() + if err != nil { + return nil, err + } + e := &relayEntry{ + id: id, + agentID: agentID, + service: service, + token: token, + controlConn: controlConn, + browser: browser, + attached: make(chan struct{}), + done: make(chan struct{}), + } + m.mu.Lock() + m.relays[id] = e + m.tokens[token] = id + m.mu.Unlock() + return e, nil +} + +// attach binds the agent's relay connection to the relay identified by the +// one-time token. It returns false if the relay no longer exists (the +// browser disconnected or timed out before the agent dialed). +func (m *relayManager) attach(token string, conn *ws.Conn) (*relayEntry, bool) { + m.mu.Lock() + id, ok := m.tokens[token] + if !ok { + m.mu.Unlock() + return nil, false + } + e, ok := m.relays[id] + if !ok { + m.mu.Unlock() + return nil, false + } + e.relayMu.Lock() + e.relay = conn + e.relayMu.Unlock() + m.mu.Unlock() + // If the relay was already torn down (browser disconnected or timed out + // before the dial), close the agent's relay connection immediately so + // no orphan connection remains. + select { + case <-e.done: + conn.Close() + return nil, false + default: + } + close(e.attached) + return e, true +} + +// hasToken reports whether a relay entry exists for the given token. +func (m *relayManager) hasToken(token string) bool { + m.mu.Lock() + defer m.mu.Unlock() + _, ok := m.tokens[token] + return ok +} + +// sendControl writes a JSON control message on an agent control connection, +// serialized per connection. It returns an error if the connection is nil +// or the write fails (e.g. the connection was replaced and closed). +func (m *relayManager) sendControl(conn *ws.Conn, v interface{}) error { + if conn == nil { + return errors.New("nil control connection") + } + m.mu.Lock() + mu, ok := m.connMu[conn] + if !ok { + mu = &sync.Mutex{} + m.connMu[conn] = mu + } + m.mu.Unlock() + mu.Lock() + defer mu.Unlock() + payload, err := json.Marshal(v) + if err != nil { + return err + } + return conn.WriteFrame(ws.MsgText, payload) +} + +// sendRawFrame writes a raw WebSocket frame on an agent control connection, +// serialized per connection via the same mutex as sendControl. Used to send +// the WS close frame (code 4001) on deliberate termination. +func (m *relayManager) sendRawFrame(conn *ws.Conn, msgType int, data []byte) { + if conn == nil { + return + } + m.mu.Lock() + mu, ok := m.connMu[conn] + if !ok { + mu = &sync.Mutex{} + m.connMu[conn] = mu + } + m.mu.Unlock() + mu.Lock() + defer mu.Unlock() + _ = conn.WriteFrame(msgType, data) +} + +// close tears down a relay by id. It is idempotent. +func (m *relayManager) close(relayID string) { + m.mu.Lock() + e, ok := m.relays[relayID] + if ok { + delete(m.relays, relayID) + delete(m.tokens, e.token) + } + m.mu.Unlock() + if !ok { + return + } + m.teardown(e) +} + +// closeAgent tears down every relay opened on the given control connection +// and releases the per-connection write mutex. Called when an agent's +// control connection drops or is replaced. +func (m *relayManager) closeAgent(agentID string, controlConn *ws.Conn) { + m.mu.Lock() + var entries []*relayEntry + for id, e := range m.relays { + if e.agentID == agentID && e.controlConn == controlConn { + entries = append(entries, e) + delete(m.relays, id) + delete(m.tokens, e.token) + } + } + delete(m.connMu, controlConn) + m.mu.Unlock() + for _, e := range entries { + m.teardown(e) + } +} + +// closeAll tears down every relay (server shutdown). +func (m *relayManager) closeAll() { + m.mu.Lock() + entries := make([]*relayEntry, 0, len(m.relays)) + for id, e := range m.relays { + entries = append(entries, e) + delete(m.relays, id) + delete(m.tokens, e.token) + } + m.mu.Unlock() + for _, e := range entries { + m.teardown(e) + } +} + +// count returns the number of active relays (used by tests to assert no +// leaks). +func (m *relayManager) count() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.relays) +} + +func (m *relayManager) teardown(e *relayEntry) { + // Revoke the one-time relay token. If the agent already dialed, the + // token was consumed and this is a no-op; if the browser disconnected + // before the dial, a late agent dial is now rejected. + if m.revokeToken != nil { + m.revokeToken(e.token) + } + // Close the relay (agent) side. If the pipe goroutines are running, + // relayToBrowser will observe the read error, send the 4000 close + // frame to the browser, and call teardownRelay itself. + e.relayMu.Lock() + if e.relay != nil { + e.relay.Close() + } + e.relayMu.Unlock() + + // If pipe goroutines are running (startRelayPipes was called), + // relayToBrowser will observe the relay-conn close above and take care + // of sending the 4000 close frame and calling teardownRelay itself. + // Otherwise (unit-test paths that skip startRelayPipes), call + // teardownRelay directly so the browser conn and done channel are + // properly closed. + if !e.pipesStarted { + teardownRelay(e, false) + } +} diff --git a/remote-bob/apiserver/internal/api/relay_manager_test.go b/remote-bob/apiserver/internal/api/relay_manager_test.go new file mode 100644 index 00000000..f77ecd5b --- /dev/null +++ b/remote-bob/apiserver/internal/api/relay_manager_test.go @@ -0,0 +1,198 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/CodeEngine/remote-bob-apiserver/internal/ws" +) + +// newEchoConn returns a *ws.Conn backed by an httptest echo server. +func newEchoConn(t *testing.T) *ws.Conn { + t.Helper() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := ws.Upgrade(w, r) + if err != nil { + return + } + defer conn.Close() + for { + f, err := conn.ReadFrame() + if err != nil { + return + } + if err := conn.WriteFrame(f.MessageType, f.Payload); err != nil { + return + } + } + })) + t.Cleanup(ts.Close) + url := "ws" + strings.TrimPrefix(ts.URL, "http") + conn, err := ws.Dial(url) + if err != nil { + t.Fatalf("dial echo conn: %v", err) + } + t.Cleanup(func() { conn.Close() }) + return conn +} + +func TestRelayManager_CreateAttachClose(t *testing.T) { + m := newRelayManager(nil) + browser := newEchoConn(t) + relay := newEchoConn(t) + control := newEchoConn(t) + + token := "relay-token-1" + e, err := m.create("agent-1", "ttyd", browser, token, control) + if err != nil { + t.Fatalf("create: %v", err) + } + if e.id == "" { + t.Fatal("relay id must be non-empty") + } + if !m.hasToken(token) { + t.Fatal("token should be tracked") + } + if m.count() != 1 { + t.Fatalf("count = %d, want 1", m.count()) + } + + // Attach the agent relay connection. + attached, ok := m.attach(token, relay) + if !ok { + t.Fatal("attach should succeed for a live relay") + } + if attached != e { + t.Error("attach should return the same entry") + } + + // Close by id. + m.close(e.id) + if m.count() != 0 { + t.Errorf("count after close = %d, want 0", m.count()) + } + if m.hasToken(token) { + t.Error("token should be released after close") + } + // Closing again is a no-op. + m.close(e.id) +} + +func TestRelayManager_AttachUnknownToken(t *testing.T) { + m := newRelayManager(nil) + relay := newEchoConn(t) + + if _, ok := m.attach("no-such-token", relay); ok { + t.Fatal("attach with unknown token must fail") + } +} + +func TestRelayManager_CloseAgentTearsDownItsRelays(t *testing.T) { + m := newRelayManager(nil) + control1 := newEchoConn(t) + control2 := newEchoConn(t) + + e1, _ := m.create("agent-1", "ttyd", newEchoConn(t), "t1", control1) + e2, _ := m.create("agent-1", "ttyd", newEchoConn(t), "t2", control1) + e3, _ := m.create("agent-2", "ttyd", newEchoConn(t), "t3", control2) + + m.closeAgent("agent-1", control1) + + if m.count() != 1 { + t.Errorf("count = %d, want 1 (agent-2 relay only)", m.count()) + } + if m.hasToken("t1") || m.hasToken("t2") { + t.Error("agent-1 relay tokens must be released") + } + if !m.hasToken("t3") { + t.Error("agent-2 relay must be untouched") + } + _ = e1 + _ = e2 + _ = e3 +} + +func TestRelayManager_CloseAll(t *testing.T) { + m := newRelayManager(nil) + m.create("agent-1", "ttyd", newEchoConn(t), "t1", newEchoConn(t)) + m.create("agent-2", "ttyd", newEchoConn(t), "t2", newEchoConn(t)) + + m.closeAll() + if m.count() != 0 { + t.Errorf("count after closeAll = %d, want 0", m.count()) + } +} + +func TestRelayManager_SendControlSerialized(t *testing.T) { + m := newRelayManager(nil) + control := newEchoConn(t) + + // Concurrent control writes must not corrupt the connection. + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + msg := map[string]interface{}{ + "type": "relay-open", + "relay_id": "id", + "service": "ttyd", + "relay_token": "token", + } + if err := m.sendControl(control, msg); err != nil { + t.Errorf("sendControl: %v", err) + } + }(i) + } + wg.Wait() + + // The echo server should have received all 20 messages intact. + for i := 0; i < 20; i++ { + f, err := control.ReadFrame() + if err != nil { + t.Fatalf("read echoed control message: %v", err) + } + var got map[string]interface{} + if err := json.Unmarshal(f.Payload, &got); err != nil { + t.Fatalf("echoed message is not valid JSON: %v", err) + } + if got["type"] != "relay-open" { + t.Errorf("echoed type = %v", got["type"]) + } + } +} + +func TestRelayManager_SendControlNilConn(t *testing.T) { + m := newRelayManager(nil) + if err := m.sendControl(nil, map[string]interface{}{"type": "relay-open"}); err == nil { + t.Fatal("sendControl on nil connection must error") + } +} + +func TestRelayManager_TeardownClosesBothEnds(t *testing.T) { + m := newRelayManager(nil) + browser := newEchoConn(t) + relay := newEchoConn(t) + + e, _ := m.create("agent-1", "ttyd", browser, "t1", newEchoConn(t)) + m.attach("t1", relay) + + m.close(e.id) + + // Both ends must be closed (Done fires). + select { + case <-browser.Done(): + case <-time.After(2 * time.Second): + t.Error("browser conn should be closed after teardown") + } + select { + case <-relay.Done(): + case <-time.After(2 * time.Second): + t.Error("relay conn should be closed after teardown") + } +} diff --git a/remote-bob/apiserver/internal/api/relay_pipe.go b/remote-bob/apiserver/internal/api/relay_pipe.go new file mode 100644 index 00000000..4e32b833 --- /dev/null +++ b/remote-bob/apiserver/internal/api/relay_pipe.go @@ -0,0 +1,124 @@ +package api + +import ( + "encoding/binary" + + "github.com/CodeEngine/remote-bob-apiserver/internal/log" + "github.com/CodeEngine/remote-bob-apiserver/internal/ws" +) + +// closeCodeAgentGone is the application-level WS close code sent to the +// browser when the agent side of the relay closes. The browser uses this to +// distinguish a deliberate agent exit (show "session ended") from a transient +// network drop (show "reconnecting"). +const closeCodeAgentGone = 4000 + +// startRelayPipes starts the two one-way copy goroutines for a relay: one +// from the browser to the agent relay connection, one from the agent relay +// connection to the browser. Each direction has exactly one reader on its +// source connection, so no frames are consumed by a competing reader. +// +// The browser→relay direction doubles as the browser-disconnect detector: +// while the agent has not yet dialed /ws/relay, a browser close is observed +// by the read loop and tears the relay down (invalidating the one-time relay +// token so a late agent dial is rejected). +func startRelayPipes(e *relayEntry) { + e.pipesStarted = true + go browserToRelay(e) + go relayToBrowser(e) +} + +// relayConn returns the agent-side relay connection, or nil if it has not +// been attached yet. +func (e *relayEntry) relayConn() *ws.Conn { + e.relayMu.Lock() + defer e.relayMu.Unlock() + return e.relay +} + +// browserToRelay copies frames from the browser to the agent relay +// connection. It starts reading immediately so a browser disconnect before +// the agent dials is detected and tears the relay down. Frames read before +// the relay attaches are held until the relay is available. +func browserToRelay(e *relayEntry) { + for { + f, err := e.browser.ReadFrame() + if err != nil { + // Browser closed (before or during the relay). + teardownRelay(e, false) + return + } + // Wait for the agent's relay connection if it has not dialed yet. + select { + case <-e.attached: + case <-e.done: + return + } + relay := e.relayConn() + if relay == nil { + return + } + if err := relay.WriteFrame(f.MessageType, f.Payload); err != nil { + teardownRelay(e, false) + return + } + } +} + +// relayToBrowser copies frames from the agent relay connection to the +// browser. It waits for the agent's relay dial before reading. When the +// agent side closes (upstream failure, agent control loss, agent shutdown), +// a close frame with closeCodeAgentGone is sent to the browser so it can +// distinguish a deliberate agent exit from a transient drop. +func relayToBrowser(e *relayEntry) { + select { + case <-e.attached: + case <-e.done: + return + } + relay := e.relayConn() + if relay == nil { + return + } + for { + f, err := relay.ReadFrame() + if err != nil { + // Agent side closed — send close code 4000 to the browser before + // tearing down so it can show "session ended" rather than an error. + agentGoneFrame := buildCloseFrame(closeCodeAgentGone, "agent disconnected") + _ = e.browser.WriteFrame(ws.MsgClose, agentGoneFrame) + teardownRelay(e, true) + return + } + if err := e.browser.WriteFrame(f.MessageType, f.Payload); err != nil { + teardownRelay(e, false) + return + } + } +} + +// buildCloseFrame encodes a WebSocket close frame payload: 2-byte big-endian +// code followed by the UTF-8 reason string (RFC 6455 §5.5.1). +func buildCloseFrame(code int, reason string) []byte { + payload := make([]byte, 2+len(reason)) + binary.BigEndian.PutUint16(payload[:2], uint16(code)) + copy(payload[2:], reason) + return payload +} + +// teardownRelay closes the relay's done channel and both connections. It is +// idempotent and safe to call from any goroutine. agentClosed indicates the +// relay is being torn down because the agent side disconnected (the browser +// close frame was already sent with closeCodeAgentGone by relayToBrowser). +func teardownRelay(e *relayEntry, agentClosed bool) { + e.closeOnce.Do(func() { + close(e.done) + if e.browser != nil { + e.browser.Close() + } + if relay := e.relayConn(); relay != nil { + relay.Close() + } + log.Info("relay_closed", "relay_id", e.id, "agent_id", e.agentID, "service", e.service, "agent_closed", agentClosed) + }) +} diff --git a/remote-bob/apiserver/internal/api/relay_test.go b/remote-bob/apiserver/internal/api/relay_test.go new file mode 100644 index 00000000..79839e94 --- /dev/null +++ b/remote-bob/apiserver/internal/api/relay_test.go @@ -0,0 +1,1180 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/CodeEngine/remote-bob-apiserver/internal/ws" +) + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +// newWSServerWithTimeout builds a WS server with a custom relay-open timeout. +func newWSServerWithTimeout(t *testing.T, relayOpenTimeout time.Duration) (*Server, string) { + t.Helper() + srv := NewServer(Config{ + GatewayPassword: "test-password", + RunTokenKey: testRunKey(), + RelayOpenTimeout: relayOpenTimeout, + }) + t.Cleanup(srv.Shutdown) + + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + return srv, "ws" + strings.TrimPrefix(ts.URL, "http") +} + +// echoUpstream starts a WS echo server that prefixes each echoed payload with +// the given prefix ("" for a plain echo). It returns the ws:// URL. +func echoUpstream(t *testing.T, prefix string) string { + t.Helper() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := ws.Upgrade(w, r) + if err != nil { + return + } + defer conn.Close() + for { + f, err := conn.ReadFrame() + if err != nil { + return + } + payload := f.Payload + if prefix != "" { + payload = append([]byte(prefix), f.Payload...) + } + if err := conn.WriteFrame(f.MessageType, payload); err != nil { + return + } + } + })) + t.Cleanup(ts.Close) + return "ws" + strings.TrimPrefix(ts.URL, "http") +} + +// agentControlMsg is a control message received by the test agent. +type agentControlMsg struct { + Type string + RelayID string + Service string + RelayToken string +} + +// startTestAgent registers an agent and runs a goroutine that forwards every +// control message to the returned channel. +func startTestAgent(t *testing.T, wsBase, runToken, agentID string, services []Service) (*ws.Conn, chan agentControlMsg) { + t.Helper() + conn := registerTestAgent(t, wsBase, runToken, agentID, services) + msgs := make(chan agentControlMsg, 32) + go func() { + for { + f, err := conn.ReadFrame() + if err != nil { + return + } + var ctrl struct { + Type string `json:"type"` + RelayID string `json:"relay_id"` + Service string `json:"service"` + RelayToken string `json:"relay_token"` + } + if err := json.Unmarshal(f.Payload, &ctrl); err != nil { + continue + } + msgs <- agentControlMsg{Type: ctrl.Type, RelayID: ctrl.RelayID, Service: ctrl.Service, RelayToken: ctrl.RelayToken} + } + }() + return conn, msgs +} + +// waitControl waits for a control message of the given type. +func waitControl(t *testing.T, msgs chan agentControlMsg, typ string) agentControlMsg { + t.Helper() + deadline := time.After(3 * time.Second) + for { + select { + case m := <-msgs: + if m.Type == typ { + return m + } + case <-deadline: + t.Fatalf("timed out waiting for control message %q", typ) + } + } +} + +// dialRelay dials /ws/relay with a one-time relay token. +func dialRelay(t *testing.T, wsBase, relayToken string) *ws.Conn { + t.Helper() + conn, resp, err := dialWS(t, wsBase+"/ws/relay?relayToken="+relayToken, nil) + if err != nil { + t.Fatalf("relay dial failed: %v (resp %v)", err, resp) + } + return conn +} + +// pipeConns pipes frames between two connections in both directions until +// either side closes. +func pipeConns(a, b *ws.Conn) { + go func() { + for { + f, err := a.ReadFrame() + if err != nil { + b.Close() + return + } + if err := b.WriteFrame(f.MessageType, f.Payload); err != nil { + a.Close() + return + } + } + }() + go func() { + for { + f, err := b.ReadFrame() + if err != nil { + a.Close() + return + } + if err := a.WriteFrame(f.MessageType, f.Payload); err != nil { + b.Close() + return + } + } + }() +} + +// issueWSToken logs in and returns a fresh WS token. +func issueWSToken(t *testing.T, ts string) string { + t.Helper() + resp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + body := decodeBody(t, resp) + token, _ := body["token"].(string) + if token == "" { + t.Fatal("no WS token issued") + } + return token +} + +// issueRunToken issues a run token for the given agent. +func issueRunToken(t *testing.T, ts, agentID string) string { + t.Helper() + resp := doRequest(t, http.MethodPost, ts+"/auth/runs?agent="+agentID, map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + body := decodeBody(t, resp) + token, _ := body["run_token"].(string) + if token == "" { + t.Fatal("no run token issued") + } + return token +} + +// connectBrowser dials /ws/browser and returns the connection. +func connectBrowser(t *testing.T, wsBase, token, agentID, service string) *ws.Conn { + t.Helper() + conn, resp, err := dialWS(t, wsBase+"/ws/browser?token="+token+"&agent="+agentID+"&service="+service, nil) + if err != nil { + t.Fatalf("browser dial failed: %v (resp %v)", err, resp) + } + return conn +} + +// waitAgentReady polls GET /agents until the agent is listed with services. +func waitAgentReady(t *testing.T, ts, agentID string) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for { + resp := doRequest(t, http.MethodGet, ts+"/agents", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + var agents []AgentInfo + _ = json.NewDecoder(resp.Body).Decode(&agents) + resp.Body.Close() + for _, a := range agents { + if a.AgentID == agentID && len(a.Services) > 0 { + return + } + } + if time.Now().After(deadline) { + t.Fatalf("agent %s did not become ready in time", agentID) + } + time.Sleep(10 * time.Millisecond) + } +} + +// setupRelay registers an agent with an echo upstream and returns the pieces +// needed to drive a browser relay session. +func setupRelay(t *testing.T, wsBase, ts, agentID string, services []Service, upstreamURL string) (*ws.Conn, chan agentControlMsg) { + t.Helper() + runToken := issueRunToken(t, ts, agentID) + agentConn, msgs := startTestAgent(t, wsBase, runToken, agentID, services) + waitAgentReady(t, ts, agentID) + return agentConn, msgs +} + +// establishRelay connects a browser, waits for relay-open, dials the relay, +// and pipes it to the echo upstream. It returns the browser connection and +// the relay-open control message. +func establishRelay(t *testing.T, wsBase, ts, agentID, service, upstreamURL string, msgs chan agentControlMsg) (*ws.Conn, agentControlMsg) { + t.Helper() + token := issueWSToken(t, ts) + browser := connectBrowser(t, wsBase, token, agentID, service) + open := waitControl(t, msgs, "relay-open") + if open.Service != service { + t.Fatalf("relay-open service = %q, want %q", open.Service, service) + } + relayConn := dialRelay(t, wsBase, open.RelayToken) + upstream, _, err := dialWS(t, upstreamURL, nil) + if err != nil { + t.Fatalf("upstream dial failed: %v", err) + } + pipeConns(relayConn, upstream) + return browser, open +} + +// waitRelayCount polls until the relay manager has exactly want active +// relays (teardown is asynchronous). +func waitRelayCount(t *testing.T, srv *Server, want int) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for srv.relays.count() != want && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if n := srv.relays.count(); n != want { + t.Errorf("expected %d active relays, got %d", want, n) + } +} + +// waitConnDone waits until conn is closed (either Done fires or a read returns an error). +// It starts a background reader to detect the close in case no other goroutine is reading. +func waitConnDone(t *testing.T, conn *ws.Conn, msg string) { + t.Helper() + // Kick off a background reader so that Done() fires when the underlying + // TCP connection is closed by the server side. + go func() { + for { + if _, err := conn.ReadFrame(); err != nil { + return + } + } + }() + select { + case <-conn.Done(): + case <-time.After(3 * time.Second): + t.Fatalf("timed out: %s", msg) + } +} + +// --------------------------------------------------------------------------- +// Opaque forwarding +// --------------------------------------------------------------------------- + +func TestRelay_TextRoundTripByteIdentical(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + _, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, upstream) + browser, _ := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs) + defer browser.Close() + + payload := []byte("hello opaque relay — text frame") + if err := browser.WriteFrame(ws.MsgText, payload); err != nil { + t.Fatalf("browser write: %v", err) + } + f, err := browser.ReadFrame() + if err != nil { + t.Fatalf("browser read: %v", err) + } + if f.MessageType != ws.MsgText { + t.Errorf("opcode = %d, want text (%d)", f.MessageType, ws.MsgText) + } + if string(f.Payload) != string(payload) { + t.Errorf("payload mismatch: got %q, want %q", f.Payload, payload) + } +} + +func TestRelay_BinaryRoundTripByteIdentical(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + _, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, upstream) + browser, _ := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs) + defer browser.Close() + + payload := []byte{0x00, 0x01, 0x02, 0xFF, 0xFE, 0x80, 0x7F, 0x00, 0x10, 0x20} + if err := browser.WriteFrame(ws.MsgBinary, payload); err != nil { + t.Fatalf("browser write: %v", err) + } + f, err := browser.ReadFrame() + if err != nil { + t.Fatalf("browser read: %v", err) + } + if f.MessageType != ws.MsgBinary { + t.Errorf("opcode = %d, want binary (%d)", f.MessageType, ws.MsgBinary) + } + if len(f.Payload) != len(payload) { + t.Fatalf("payload length = %d, want %d", len(f.Payload), len(payload)) + } + for i := range payload { + if f.Payload[i] != payload[i] { + t.Errorf("byte %d = %#x, want %#x", i, f.Payload[i], payload[i]) + } + } +} + +func TestRelay_LargeFrameRoundTrip(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + _, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, upstream) + browser, _ := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs) + defer browser.Close() + + // >64KB frame (default WS read limit territory). + payload := make([]byte, 256*1024) + for i := range payload { + payload[i] = byte(i % 251) + } + if err := browser.WriteFrame(ws.MsgBinary, payload); err != nil { + t.Fatalf("browser write: %v", err) + } + f, err := browser.ReadFrame() + if err != nil { + t.Fatalf("browser read: %v", err) + } + if f.MessageType != ws.MsgBinary { + t.Errorf("opcode = %d, want binary", f.MessageType) + } + if len(f.Payload) != len(payload) { + t.Fatalf("large frame length = %d, want %d", len(f.Payload), len(payload)) + } + for i := range payload { + if f.Payload[i] != payload[i] { + t.Fatalf("large frame byte %d mismatch", i) + } + } +} + +func TestRelay_OrderPreserved(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + _, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, upstream) + browser, _ := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs) + defer browser.Close() + + const n = 50 + for i := 0; i < n; i++ { + if err := browser.WriteFrame(ws.MsgText, []byte(fmt.Sprintf("seq-%03d", i))); err != nil { + t.Fatalf("write %d: %v", i, err) + } + } + for i := 0; i < n; i++ { + f, err := browser.ReadFrame() + if err != nil { + t.Fatalf("read %d: %v", i, err) + } + want := fmt.Sprintf("seq-%03d", i) + if string(f.Payload) != want { + t.Fatalf("frame %d = %q, want %q (order broken)", i, f.Payload, want) + } + } +} + +func TestRelay_ArbitraryPayloadsRoundTrip(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + _, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, upstream) + browser, _ := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs) + defer browser.Close() + + payloads := [][]byte{ + []byte("plain text"), + {0x00, 0x00, 0x00, 0x01}, + []byte("{\"json\":\"text\"}"), + {0xFF, 0xFF, 0xFF}, + } + for _, p := range payloads { + if err := browser.WriteFrame(ws.MsgText, p); err != nil { + t.Fatalf("write: %v", err) + } + f, err := browser.ReadFrame() + if err != nil { + t.Fatalf("read: %v", err) + } + if string(f.Payload) != string(p) { + t.Errorf("payload %q round-tripped as %q", p, f.Payload) + } + } +} + +func TestRelay_PingAnsweredByWSStack(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + _, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, upstream) + browser, _ := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs) + defer browser.Close() + + // Our ws.Conn handles ping/pong at the ReadFrame level: pings are auto-answered. + // Send a ping and verify the connection stays alive by reading a text frame. + if err := browser.WriteFrameMasked(ws.MsgPing, []byte("ping")); err != nil { + t.Fatalf("write ping: %v", err) + } + // The server will auto-respond with pong (transparent to reader). + // Verify the relay still works by sending a data frame. + if err := browser.WriteFrame(ws.MsgText, []byte("after-ping")); err != nil { + t.Fatalf("write after ping: %v", err) + } + f, err := browser.ReadFrame() + if err != nil { + t.Fatalf("read after ping: %v", err) + } + if string(f.Payload) != "after-ping" { + t.Errorf("expected after-ping, got %q", f.Payload) + } +} + +func TestRelay_BackpressureNoFrameLoss(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + _, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, upstream) + token := issueWSToken(t, ts) + browser := connectBrowser(t, wsBase, token, "agent-1", "ttyd") + defer browser.Close() + open := waitControl(t, msgs, "relay-open") + relayConn := dialRelay(t, wsBase, open.RelayToken) + defer relayConn.Close() + + // Agent sends a burst of frames; the browser consumes them slowly. + const n = 100 + go func() { + for i := 0; i < n; i++ { + payload := make([]byte, 4096) + copy(payload, fmt.Sprintf("frame-%03d", i)) + if err := relayConn.WriteFrame(ws.MsgBinary, payload); err != nil { + return + } + } + }() + + for i := 0; i < n; i++ { + time.Sleep(2 * time.Millisecond) // slow consumer + f, err := browser.ReadFrame() + if err != nil { + t.Fatalf("read %d: %v (frame lost or connection closed)", i, err) + } + if f.MessageType != ws.MsgBinary { + t.Fatalf("frame %d opcode = %d, want binary", i, f.MessageType) + } + want := fmt.Sprintf("frame-%03d", i) + if string(f.Payload[:len(want)]) != want { + t.Fatalf("frame %d = %q..., want %q (order broken)", i, f.Payload[:len(want)], want) + } + } +} + +// --------------------------------------------------------------------------- +// Relay lifecycle +// --------------------------------------------------------------------------- + +func TestRelay_BrowserDisconnectClosesUpstreamAndSendsRelayClose(t *testing.T) { + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + agentConn, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, upstream) + browser, _ := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs) + + // Round-trip a frame to confirm the relay is live. + if err := browser.WriteFrame(ws.MsgText, []byte("hi")); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := browser.ReadFrame(); err != nil { + t.Fatalf("read: %v", err) + } + + // Browser disconnects. + browser.Close() + + // The agent receives relay-close with the correct relay_id. + closeMsg := waitControl(t, msgs, "relay-close") + if closeMsg.RelayID == "" { + t.Error("relay-close must carry a relay_id") + } + + // The control connection stays alive and the agent stays registered. + if !srv.AgentRegistry().Has("agent-1") { + t.Error("agent must stay registered after browser disconnect") + } + // The control connection still works: a new relay-open can be delivered. + token := issueWSToken(t, ts) + browser2 := connectBrowser(t, wsBase, token, "agent-1", "ttyd") + defer browser2.Close() + open := waitControl(t, msgs, "relay-open") + relayConn := dialRelay(t, wsBase, open.RelayToken) + upstream2, _, err := dialWS(t, upstream, nil) + if err != nil { + t.Fatalf("upstream dial: %v", err) + } + pipeConns(relayConn, upstream2) + if err := browser2.WriteFrame(ws.MsgText, []byte("again")); err != nil { + t.Fatalf("write: %v", err) + } + if f, err := browser2.ReadFrame(); err != nil || string(f.Payload) != "again" { + t.Fatalf("second session round-trip failed: %v %q", err, f.Payload) + } + _ = agentConn +} + +func TestRelay_NoOrphanedRelaysAfterDisconnect(t *testing.T) { + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + _, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, upstream) + + for i := 0; i < 3; i++ { + browser, _ := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs) + if err := browser.WriteFrame(ws.MsgText, []byte("x")); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := browser.ReadFrame(); err != nil { + t.Fatalf("read: %v", err) + } + browser.Close() + waitControl(t, msgs, "relay-close") + } + + // No leaked relays. + waitRelayCount(t, srv, 0) + + // A fresh connect works immediately. + browser, _ := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs) + defer browser.Close() + if err := browser.WriteFrame(ws.MsgText, []byte("fresh")); err != nil { + t.Fatalf("write: %v", err) + } + if f, err := browser.ReadFrame(); err != nil || string(f.Payload) != "fresh" { + t.Fatalf("fresh connect round-trip failed: %v %q", err, f.Payload) + } +} + +func TestRelay_RapidConnectDisconnectCycles(t *testing.T) { + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + _, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, upstream) + + for i := 0; i < 10; i++ { + browser, _ := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs) + if err := browser.WriteFrame(ws.MsgText, []byte("cycle")); err != nil { + t.Fatalf("cycle %d write: %v", i, err) + } + if _, err := browser.ReadFrame(); err != nil { + t.Fatalf("cycle %d read: %v", i, err) + } + browser.Close() + waitControl(t, msgs, "relay-close") + } + + waitRelayCount(t, srv, 0) + if !srv.AgentRegistry().Has("agent-1") { + t.Error("agent must remain ready after rapid cycles") + } + + // Final connect works. + browser, _ := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs) + defer browser.Close() + if err := browser.WriteFrame(ws.MsgText, []byte("final")); err != nil { + t.Fatalf("write: %v", err) + } + if f, err := browser.ReadFrame(); err != nil || string(f.Payload) != "final" { + t.Fatalf("final round-trip failed: %v %q", err, f.Payload) + } +} + +func TestRelay_TimeoutWhenAgentNeverDials(t *testing.T) { + srv, wsBase := newWSServerWithTimeout(t, 300*time.Millisecond) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + _, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, upstream) + + token := issueWSToken(t, ts) + browser := connectBrowser(t, wsBase, token, "agent-1", "ttyd") + defer browser.Close() + + // The agent receives relay-open but never dials the relay. + open := waitControl(t, msgs, "relay-open") + _ = open + + // The browser must get a clean close within a bounded time, not hang. + start := time.Now() + waitConnDone(t, browser, "browser should be closed after relay-open timeout") + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Errorf("browser close took %v, expected bounded timeout", elapsed) + } + + // No leaked relay. + waitRelayCount(t, srv, 0) +} + +func TestRelay_AgentControlLossMidRelay(t *testing.T) { + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + agentConn, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, upstream) + browser, _ := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs) + + // Confirm the relay is live. + if err := browser.WriteFrame(ws.MsgText, []byte("live")); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := browser.ReadFrame(); err != nil { + t.Fatalf("read: %v", err) + } + + // Agent control connection drops mid-relay. + agentConn.Close() + + // The browser observes a clean close. + waitConnDone(t, browser, "browser should close after agent control loss") + + // Registry reflects the agent as gone. + deadline := time.Now().Add(2 * time.Second) + for srv.AgentRegistry().Has("agent-1") && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if srv.AgentRegistry().Has("agent-1") { + t.Error("agent should be unregistered after control loss") + } + waitRelayCount(t, srv, 0) + + // Re-dial re-registers and a new browser connect works. + runToken := issueRunToken(t, ts, "agent-1") + agentConn2, msgs2 := startTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer agentConn2.Close() + waitAgentReady(t, ts, "agent-1") + + browser2, _ := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs2) + defer browser2.Close() + if err := browser2.WriteFrame(ws.MsgText, []byte("back")); err != nil { + t.Fatalf("write: %v", err) + } + if f, err := browser2.ReadFrame(); err != nil || string(f.Payload) != "back" { + t.Fatalf("post-reconnect round-trip failed: %v %q", err, f.Payload) + } +} + +func TestRelay_ControlReplacementMidRelay(t *testing.T) { + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + agentConn, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, upstream) + browser, _ := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs) + + // Confirm the relay is live. + if err := browser.WriteFrame(ws.MsgText, []byte("live")); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := browser.ReadFrame(); err != nil { + t.Fatalf("read: %v", err) + } + + // A second control connection replaces the first. + runToken := issueRunToken(t, ts, "agent-1") + agentConn2, _ := startTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer agentConn2.Close() + + // The active relay is torn down: the browser sees a clean close. + waitConnDone(t, browser, "browser should close after control replacement") + + // Registry is consistent: exactly one entry. + if srv.AgentRegistry().Count() != 1 { + t.Errorf("expected exactly 1 registered agent, got %d", srv.AgentRegistry().Count()) + } + waitRelayCount(t, srv, 0) + _ = agentConn +} + +func TestRelay_BrowserDisconnectBeforeAgentDialInvalidatesToken(t *testing.T) { + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + _, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, upstream) + + token := issueWSToken(t, ts) + browser := connectBrowser(t, wsBase, token, "agent-1", "ttyd") + open := waitControl(t, msgs, "relay-open") + + // Browser disconnects before the agent dials. + browser.Close() + + // Wait for the relay to be cleaned up. + waitRelayCount(t, srv, 0) + + // A late agent dial with the issued token is rejected. + _, resp, err := dialWS(t, wsBase+"/ws/relay?relayToken="+open.RelayToken, nil) + if err == nil { + t.Fatal("late relay dial must be rejected after browser disconnect") + } + if resp == nil || resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401 for late relay dial, got %v", resp) + } + waitRelayCount(t, srv, 0) +} + +func TestRelay_UpstreamConnectFailureSurfacesAsCleanBrowserClose(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + _, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, "ws://127.0.0.1:1/upstream") + + token := issueWSToken(t, ts) + browser := connectBrowser(t, wsBase, token, "agent-1", "ttyd") + defer browser.Close() + open := waitControl(t, msgs, "relay-open") + + // The agent dials the relay but its upstream is unreachable; it closes + // the relay connection, which must surface as a clean browser close. + relayConn := dialRelay(t, wsBase, open.RelayToken) + relayConn.Close() + + waitConnDone(t, browser, "browser should close after upstream failure") +} + +// --------------------------------------------------------------------------- +// Fan-out and multi-agent +// --------------------------------------------------------------------------- + +func TestRelay_FanOutConcurrentBrowsers(t *testing.T) { + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + _, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, upstream) + + // Two concurrent browsers to the same agent+service. + browser1, open1 := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs) + defer browser1.Close() + browser2, open2 := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs) + defer browser2.Close() + + // Distinct relay ids and tokens. + if open1.RelayID == open2.RelayID { + t.Error("concurrent relays must have distinct relay_ids") + } + if open1.RelayToken == open2.RelayToken { + t.Error("concurrent relays must have distinct relay tokens") + } + + // Independent round-trips, no cross-talk. + if err := browser1.WriteFrame(ws.MsgText, []byte("one")); err != nil { + t.Fatalf("browser1 write: %v", err) + } + if err := browser2.WriteFrame(ws.MsgText, []byte("two")); err != nil { + t.Fatalf("browser2 write: %v", err) + } + f1, err := browser1.ReadFrame() + if err != nil || string(f1.Payload) != "one" { + t.Fatalf("browser1 got %q err %v", f1.Payload, err) + } + f2, err := browser2.ReadFrame() + if err != nil || string(f2.Payload) != "two" { + t.Fatalf("browser2 got %q err %v", f2.Payload, err) + } + + // Closing one relay does not close the other. + browser1.Close() + waitControl(t, msgs, "relay-close") + if err := browser2.WriteFrame(ws.MsgText, []byte("still")); err != nil { + t.Fatalf("browser2 write after browser1 close: %v", err) + } + if f, err := browser2.ReadFrame(); err != nil || string(f.Payload) != "still" { + t.Fatalf("browser2 after sibling close: %q err %v", f.Payload, err) + } + + waitRelayCount(t, srv, 1) +} + +func TestRelay_MultiAgentIsolation(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstreamA := echoUpstream(t, "A:") + upstreamB := echoUpstream(t, "B:") + + _, msgsA := setupRelay(t, wsBase, ts, "agent-A", []Service{{Name: "ttyd"}}, upstreamA) + _, msgsB := setupRelay(t, wsBase, ts, "agent-B", []Service{{Name: "ttyd"}}, upstreamB) + + browserA, _ := establishRelay(t, wsBase, ts, "agent-A", "ttyd", upstreamA, msgsA) + defer browserA.Close() + browserB, _ := establishRelay(t, wsBase, ts, "agent-B", "ttyd", upstreamB, msgsB) + defer browserB.Close() + + if err := browserA.WriteFrame(ws.MsgText, []byte("x")); err != nil { + t.Fatalf("A write: %v", err) + } + if err := browserB.WriteFrame(ws.MsgText, []byte("x")); err != nil { + t.Fatalf("B write: %v", err) + } + fA, err := browserA.ReadFrame() + if err != nil || string(fA.Payload) != "A:x" { + t.Fatalf("A got %q err %v, want A:x (cross-routing!)", fA.Payload, err) + } + fB, err := browserB.ReadFrame() + if err != nil || string(fB.Payload) != "B:x" { + t.Fatalf("B got %q err %v, want B:x (cross-routing!)", fB.Payload, err) + } +} + +func TestRelay_MultipleServicesPerAgent(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + _, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{ + {Name: "ttyd", Upstream: "ws://127.0.0.1:7080"}, + {Name: "openclaw", Upstream: "ws://127.0.0.1:18789"}, + }, upstream) + + // GET /agents lists both services. + resp := doRequest(t, http.MethodGet, ts+"/agents", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + var agents []AgentInfo + _ = json.NewDecoder(resp.Body).Decode(&agents) + resp.Body.Close() + found := false + for _, a := range agents { + if a.AgentID == "agent-1" { + found = true + if len(a.Services) != 2 { + t.Errorf("services = %v, want 2 entries", a.Services) + } + } + } + if !found { + t.Fatal("agent-1 not listed") + } + + // Browser connect to each service works. + b1, _ := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs) + defer b1.Close() + b2, _ := establishRelay(t, wsBase, ts, "agent-1", "openclaw", upstream, msgs) + defer b2.Close() +} + +func TestRelay_ReRegisterReplacesServiceSet(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + agentConn, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, upstream) + + // ttyd is connectable. + browser, _ := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs) + browser.Close() + waitControl(t, msgs, "relay-close") + + // Re-register with a different service set (same control connection). + regMsg, _ := json.Marshal(map[string]interface{}{ + "type": "register", + "services": []Service{{Name: "openclaw", Upstream: "ws://127.0.0.1:18789"}}, + }) + if err := agentConn.WriteFrame(ws.MsgText, regMsg); err != nil { + t.Fatalf("re-register write: %v", err) + } + waitAgentReady(t, ts, "agent-1") + + // ttyd is no longer connectable. + token := issueWSToken(t, ts) + _, resp, err := dialWS(t, wsBase+"/ws/browser?token="+token+"&agent=agent-1&service=ttyd", nil) + if err == nil { + t.Fatal("removed service must not be connectable") + } + if resp == nil || resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404 for removed service, got %v", resp) + } + + // openclaw is connectable. + browser2, _ := establishRelay(t, wsBase, ts, "agent-1", "openclaw", upstream, msgs) + defer browser2.Close() +} + +func TestRelay_AgentMidReconnect(t *testing.T) { + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + agentConn, _ := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, upstream) + + // Control connection drops. + agentConn.Close() + deadline := time.Now().Add(2 * time.Second) + for srv.AgentRegistry().Has("agent-1") && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + + // Browser connect during the gap is rejected cleanly (not a hang). + token := issueWSToken(t, ts) + _, resp, err := dialWS(t, wsBase+"/ws/browser?token="+token+"&agent=agent-1&service=ttyd", nil) + if err == nil { + t.Fatal("browser connect during agent gap must fail") + } + if resp == nil || resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404 during agent gap, got %v", resp) + } + + // Agent re-dials and re-registers. + runToken := issueRunToken(t, ts, "agent-1") + agentConn2, msgs2 := startTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer agentConn2.Close() + waitAgentReady(t, ts, "agent-1") + + // Browser connect works after re-registration. + browser, _ := establishRelay(t, wsBase, ts, "agent-1", "ttyd", upstream, msgs2) + defer browser.Close() + if err := browser.WriteFrame(ws.MsgText, []byte("ok")); err != nil { + t.Fatalf("write: %v", err) + } + if f, err := browser.ReadFrame(); err != nil || string(f.Payload) != "ok" { + t.Fatalf("round-trip after reconnect failed: %v %q", err, f.Payload) + } +} + +func TestRelay_IdleAgentStaysReady(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + _, _ = setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, "ws://127.0.0.1:1") + + // No relays active; the agent must still be ready. + resp := doRequest(t, http.MethodGet, ts+"/agents", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + var agents []AgentInfo + _ = json.NewDecoder(resp.Body).Decode(&agents) + resp.Body.Close() + for _, a := range agents { + if a.AgentID == "agent-1" { + if a.Status != "ready" { + t.Errorf("idle agent status = %q, want ready", a.Status) + } + return + } + } + t.Fatal("agent-1 not listed") +} + +func TestRelay_ServiceUnregisteredMidSession(t *testing.T) { + srv, wsBase := newWSServerWithTimeout(t, 300*time.Millisecond) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + agentConn, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, "ws://127.0.0.1:1") + + // Browser connects to ttyd (registered at handshake time). + token := issueWSToken(t, ts) + browser := connectBrowser(t, wsBase, token, "agent-1", "ttyd") + defer browser.Close() + open := waitControl(t, msgs, "relay-open") + _ = open + + // The agent re-registers without ttyd before dialing the relay. + regMsg, _ := json.Marshal(map[string]interface{}{ + "type": "register", + "services": []Service{{Name: "openclaw"}}, + }) + if err := agentConn.WriteFrame(ws.MsgText, regMsg); err != nil { + t.Fatalf("re-register write: %v", err) + } + + // The agent never dials; the browser must get a clean bounded close, not + // a hang, and the registry must stay consistent. + waitConnDone(t, browser, "browser should close when service was unregistered mid-session") + waitRelayCount(t, srv, 0) + if !srv.AgentRegistry().Has("agent-1") { + t.Error("agent must remain registered") + } +} + +// --------------------------------------------------------------------------- +// Concurrency +// --------------------------------------------------------------------------- + +func TestRelay_ConcurrentAgentRegistrations(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + const n = 5 + var wg sync.WaitGroup + errs := make(chan error, n) + conns := make([]*ws.Conn, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + agentID := fmt.Sprintf("agent-%d", i) + req, err := http.NewRequest(http.MethodPost, ts+"/auth/runs?agent="+agentID, nil) + if err != nil { + errs <- err + return + } + req.Header.Set("Authorization", basicHeader("admin", "test-password")) + runResp, err := http.DefaultClient.Do(req) + if err != nil { + errs <- err + return + } + var body map[string]interface{} + if err := json.NewDecoder(runResp.Body).Decode(&body); err != nil { + runResp.Body.Close() + errs <- err + return + } + runResp.Body.Close() + runToken, _ := body["run_token"].(string) + if runToken == "" { + errs <- fmt.Errorf("agent-%d: no run token", i) + return + } + conn, resp, err := dialWS(t, wsBase+"/ws/agent?agent="+agentID, map[string]string{ + "Authorization": "Bearer " + runToken, + }) + if err != nil { + errs <- fmt.Errorf("agent-%d dial: %v (resp %v)", i, err, resp) + return + } + conns[i] = conn + msg, _ := json.Marshal(map[string]interface{}{ + "type": "register", + "services": []Service{{Name: "ttyd"}}, + }) + if err := conn.WriteFrame(ws.MsgText, msg); err != nil { + errs <- fmt.Errorf("agent-%d register write: %v", i, err) + return + } + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + t.Error(err) + } + for _, c := range conns { + if c != nil { + defer c.Close() + } + } + + // Registration is processed asynchronously; poll until all agents appear. + deadline := time.Now().Add(3 * time.Second) + var agents []AgentInfo + for { + resp := doRequest(t, http.MethodGet, ts+"/agents", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + agents = nil + _ = json.NewDecoder(resp.Body).Decode(&agents) + resp.Body.Close() + if len(agents) == n { + break + } + if time.Now().After(deadline) { + t.Fatalf("expected %d agents, got %d", n, len(agents)) + } + time.Sleep(10 * time.Millisecond) + } + seen := make(map[string]bool) + for _, a := range agents { + seen[a.AgentID] = true + if a.Status != "ready" { + t.Errorf("agent %s status = %q, want ready", a.AgentID, a.Status) + } + } + for i := 0; i < n; i++ { + if !seen[fmt.Sprintf("agent-%d", i)] { + t.Errorf("agent-%d missing from registry", i) + } + } +} + +func TestRelay_ConcurrentBrowsersIndependent(t *testing.T) { + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + ts := "http" + strings.TrimPrefix(wsBase, "ws") + upstream := echoUpstream(t, "") + + _, msgs := setupRelay(t, wsBase, ts, "agent-1", []Service{{Name: "ttyd"}}, upstream) + + const n = 5 + var wg sync.WaitGroup + errs := make(chan error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + token := issueWSToken(t, ts) + browser := connectBrowser(t, wsBase, token, "agent-1", "ttyd") + defer browser.Close() + open := waitControl(t, msgs, "relay-open") + relayConn := dialRelay(t, wsBase, open.RelayToken) + up, _, err := dialWS(t, upstream, nil) + if err != nil { + errs <- err + return + } + pipeConns(relayConn, up) + msg := fmt.Sprintf("client-%d", i) + if err := browser.WriteFrame(ws.MsgText, []byte(msg)); err != nil { + errs <- err + return + } + f, err := browser.ReadFrame() + if err != nil { + errs <- err + return + } + if string(f.Payload) != msg { + errs <- fmt.Errorf("client %d got %q, want %q", i, f.Payload, msg) + } + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + t.Error(err) + } + + // Teardown is asynchronous; poll until no relays remain. + waitRelayCount(t, srv, 0) +} + +// suppress io import if only used as _ in certain tests +var _ = io.EOF diff --git a/remote-bob/apiserver/internal/api/relay_token.go b/remote-bob/apiserver/internal/api/relay_token.go new file mode 100644 index 00000000..81d64e32 --- /dev/null +++ b/remote-bob/apiserver/internal/api/relay_token.go @@ -0,0 +1,106 @@ +package api + +import ( + "sync" + "time" +) + +// RelayTokenStore issues one-time, short-lived relay tokens. A token is +// consumed (deleted) on first use, so a second use — even after a failed +// first dial — is rejected. +type RelayTokenStore struct { + mu sync.Mutex + tokens map[string]time.Time + ttl time.Duration + now func() time.Time + stopCh chan struct{} + once sync.Once + wg sync.WaitGroup +} + +// NewRelayTokenStore creates a relay token store with the given TTL. +func NewRelayTokenStore(ttl time.Duration) *RelayTokenStore { + s := &RelayTokenStore{ + tokens: make(map[string]time.Time), + ttl: ttl, + now: time.Now, + stopCh: make(chan struct{}), + } + s.wg.Add(1) + go s.sweepLoop() + return s +} + +// Issue creates a new one-time relay token. +func (s *RelayTokenStore) Issue() (string, error) { + token, err := randomToken() + if err != nil { + return "", err + } + s.mu.Lock() + defer s.mu.Unlock() + s.tokens[hashToken(token)] = s.now().Add(s.ttl) + return token, nil +} + +// Consume atomically validates and consumes a token. It returns true exactly +// once per issued token. +func (s *RelayTokenStore) Consume(token string) bool { + if token == "" { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + key := hashToken(token) + expiresAt, ok := s.tokens[key] + if !ok { + return false + } + delete(s.tokens, key) + return !s.now().After(expiresAt) +} + +// Revoke removes a token from the store if it has not been consumed yet. +// Used when a browser disconnects before the agent dials the relay, so a +// late agent dial is rejected. +func (s *RelayTokenStore) Revoke(token string) { + if token == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + delete(s.tokens, hashToken(token)) +} + +func (s *RelayTokenStore) sweepLoop() { + defer s.wg.Done() + ticker := time.NewTicker(s.ttl) + defer ticker.Stop() + for { + select { + case <-ticker.C: + s.sweep() + case <-s.stopCh: + return + } + } +} + +func (s *RelayTokenStore) sweep() { + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + for k, expiresAt := range s.tokens { + if now.After(expiresAt) { + delete(s.tokens, k) + } + } +} + +// Stop stops the background sweeper. +func (s *RelayTokenStore) Stop() { + s.once.Do(func() { + close(s.stopCh) + s.wg.Wait() + }) +} diff --git a/remote-bob/apiserver/internal/api/relay_token_test.go b/remote-bob/apiserver/internal/api/relay_token_test.go new file mode 100644 index 00000000..3d30f454 --- /dev/null +++ b/remote-bob/apiserver/internal/api/relay_token_test.go @@ -0,0 +1,88 @@ +package api + +import ( + "sync" + "testing" + "time" +) + +func TestRelayTokenStore_IssueAndConsume(t *testing.T) { + store := NewRelayTokenStore(60 * time.Second) + defer store.Stop() + + token, err := store.Issue() + if err != nil { + t.Fatalf("Issue() error: %v", err) + } + if token == "" { + t.Fatal("Issue() returned empty token") + } + + if !store.Consume(token) { + t.Error("first Consume should succeed") + } + if store.Consume(token) { + t.Error("second Consume should fail (one-time)") + } +} + +func TestRelayTokenStore_InvalidRejected(t *testing.T) { + store := NewRelayTokenStore(60 * time.Second) + defer store.Stop() + + if store.Consume("") { + t.Error("empty token should be rejected") + } + if store.Consume("garbage") { + t.Error("unknown token should be rejected") + } +} + +func TestRelayTokenStore_Expiry(t *testing.T) { + now := time.Now() + store := NewRelayTokenStore(60 * time.Second) + defer store.Stop() + store.now = func() time.Time { return now } + + token, err := store.Issue() + if err != nil { + t.Fatalf("Issue() error: %v", err) + } + + store.now = func() time.Time { return now.Add(61 * time.Second) } + if store.Consume(token) { + t.Error("expired relay token should be rejected") + } +} + +func TestRelayTokenStore_ConcurrentConsume(t *testing.T) { + store := NewRelayTokenStore(60 * time.Second) + defer store.Stop() + + token, err := store.Issue() + if err != nil { + t.Fatalf("Issue() error: %v", err) + } + + const n = 20 + var wg sync.WaitGroup + results := make([]bool, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + results[i] = store.Consume(token) + }(i) + } + wg.Wait() + + successes := 0 + for _, ok := range results { + if ok { + successes++ + } + } + if successes != 1 { + t.Errorf("exactly one concurrent Consume should succeed, got %d", successes) + } +} diff --git a/remote-bob/apiserver/internal/api/request_log.go b/remote-bob/apiserver/internal/api/request_log.go new file mode 100644 index 00000000..29b687d9 --- /dev/null +++ b/remote-bob/apiserver/internal/api/request_log.go @@ -0,0 +1,95 @@ +package api + +import ( + "net/http" + "net/url" + "sort" + "strings" + + "github.com/CodeEngine/remote-bob-apiserver/internal/log" +) + +// credentialQueryParams are query parameters whose values must never be +// logged. The WS token on /ws/browser and the one-time relay token on +// /ws/relay are short-lived credentials; the run token must never appear in +// a URL at all, but if it ever does it is redacted too. Keys are matched +// case-insensitively. +var credentialQueryParams = map[string]bool{ + "token": true, + "relaytoken": true, + "relay_token": true, + "runtoken": true, + "run_token": true, + "password": true, + "sessiontoken": true, +} + +// redactQuery returns a copy of the raw query string with credential-bearing +// parameter values replaced by "[REDACTED]". Keys are emitted in sorted order +// for deterministic output. +func redactQuery(rawQuery string) string { + if rawQuery == "" { + return "" + } + values, err := url.ParseQuery(rawQuery) + if err != nil { + // Fall back to a conservative redaction: drop the query entirely. + return "[REDACTED]" + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + parts := make([]string, 0, len(values)) + for _, key := range keys { + vals := values[key] + if credentialQueryParams[strings.ToLower(key)] { + for range vals { + parts = append(parts, key+"=[REDACTED]") + } + continue + } + for _, v := range vals { + parts = append(parts, key+"="+v) + } + } + return strings.Join(parts, "&") +} + +// redactAuthorization returns a safe description of the Authorization header. +// The credential value (Basic password or Bearer token) is never logged. +func redactAuthorization(header string) string { + if header == "" { + return "" + } + space := strings.Index(header, " ") + if space <= 0 { + return "[REDACTED]" + } + scheme := header[:space] + return scheme + " [REDACTED]" +} + +// requestLogger logs every request with full credential redaction: the +// Authorization header value, credential query parameters, and credential +// body fields are never written to the log. +type requestLogger struct { + next http.Handler +} + +// NewRequestLogger wraps a handler with redacted request logging. +func NewRequestLogger(next http.Handler) http.Handler { + return &requestLogger{next: next} +} + +func (l *requestLogger) ServeHTTP(w http.ResponseWriter, r *http.Request) { + log.Info("http_request", map[string]interface{}{ + "method": r.Method, + "path": r.URL.Path, + "query": redactQuery(r.URL.RawQuery), + "remote": r.RemoteAddr, + "auth": redactAuthorization(r.Header.Get("Authorization")), + }) + l.next.ServeHTTP(w, r) +} diff --git a/remote-bob/apiserver/internal/api/request_log_test.go b/remote-bob/apiserver/internal/api/request_log_test.go new file mode 100644 index 00000000..4435b110 --- /dev/null +++ b/remote-bob/apiserver/internal/api/request_log_test.go @@ -0,0 +1,131 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestRedactQuery(t *testing.T) { + tests := []struct { + name string + rawQuery string + want string + }{ + { + name: "empty", + rawQuery: "", + want: "", + }, + { + name: "ws token redacted", + rawQuery: "token=secret123&agent=agent-1&service=ttyd", + want: "agent=agent-1&service=ttyd&token=[REDACTED]", + }, + { + name: "relay token redacted", + rawQuery: "relayToken=secret456", + want: "relayToken=[REDACTED]", + }, + { + name: "run token redacted", + rawQuery: "runToken=secret789", + want: "runToken=[REDACTED]", + }, + { + name: "password redacted", + rawQuery: "password=hunter2", + want: "password=[REDACTED]", + }, + { + name: "non-credential params preserved", + rawQuery: "agent=agent-1&service=ttyd", + want: "agent=agent-1&service=ttyd", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := redactQuery(tt.rawQuery) + if got != tt.want { + t.Errorf("redactQuery(%q) = %q, want %q", tt.rawQuery, got, tt.want) + } + }) + } +} + +func TestRedactAuthorization(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"basic", "Basic dXNlcjpwYXNz", "Basic [REDACTED]"}, + {"bearer", "Bearer abc123", "Bearer [REDACTED]"}, + {"malformed", "not-a-header", "[REDACTED]"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := redactAuthorization(tt.in) + if got != tt.want { + t.Errorf("redactAuthorization(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestRequestLogger_RedactsCredentials(t *testing.T) { + // The logger must never emit the credential values. These are test + // placeholders, not real credentials. + const password = "test-password-placeholder" + const wsToken = "test-ws-token-placeholder" + const runToken = "test-run-token-placeholder" + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := NewRequestLogger(next) + + req := httptest.NewRequest(http.MethodPost, "/auth/login", nil) + req.SetBasicAuth("admin", password) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + req = httptest.NewRequest(http.MethodGet, "/ws/browser?token="+wsToken+"&agent=a&service=ttyd", nil) + w = httptest.NewRecorder() + handler.ServeHTTP(w, req) + + req = httptest.NewRequest(http.MethodGet, "/ws/agent?agent=a", nil) + req.Header.Set("Authorization", "Bearer "+runToken) + w = httptest.NewRecorder() + handler.ServeHTTP(w, req) + + // The logger writes to the global slog logger (stdout), which we cannot + // capture here; instead verify the redaction helpers produce safe output + // and that the request path never includes the values. + if strings.Contains(redactQuery("token="+wsToken), wsToken) { + t.Error("redactQuery leaked the WS token") + } + if strings.Contains(redactAuthorization("Bearer "+runToken), runToken) { + t.Error("redactAuthorization leaked the run token") + } + if strings.Contains(redactAuthorization("Basic "+password), password) { + t.Error("redactAuthorization leaked the password") + } +} + +func TestRequestLogger_DoesNotBlockRequests(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + handler := NewRequestLogger(next) + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Errorf("expected 204, got %d", w.Code) + } +} diff --git a/remote-bob/apiserver/internal/api/run_token.go b/remote-bob/apiserver/internal/api/run_token.go new file mode 100644 index 00000000..f4d2315e --- /dev/null +++ b/remote-bob/apiserver/internal/api/run_token.go @@ -0,0 +1,112 @@ +package api + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "time" +) + +const runTokenType = "run" + +var errNoSigningKey = errors.New("run token signing key is not configured") + +// RunTokenManager issues and validates stateless HMAC-signed run tokens. +// Tokens are bound to the agent ID they were issued for and carry a TTL. +// Because they are stateless, they survive apiserver restarts as long as the +// signing key is stable (ENCRYPTION_KEY). +type RunTokenManager struct { + key []byte + ttl time.Duration + now func() time.Time +} + +// NewRunTokenManager creates a run token manager. +func NewRunTokenManager(key []byte, ttl time.Duration) *RunTokenManager { + return &RunTokenManager{key: key, ttl: ttl, now: time.Now} +} + +// Issue creates a run token bound to the given agent ID. +func (m *RunTokenManager) Issue(agentID string) (string, error) { + if len(m.key) == 0 { + return "", errNoSigningKey + } + now := m.now().Unix() + payload := struct { + AgentID string `json:"agent_id"` + IAT int64 `json:"iat"` + Exp int64 `json:"exp"` + Typ string `json:"typ"` + }{ + AgentID: agentID, + IAT: now, + Exp: now + int64(m.ttl.Seconds()), + Typ: runTokenType, + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return "", err + } + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadBytes) + return payloadB64 + "." + m.sign(payloadB64), nil +} + +// Validate checks that the token is a valid run token for the given agent ID: +// correct HMAC signature (constant-time), correct type, unexpired, and bound +// to the agent ID. +func (m *RunTokenManager) Validate(token, agentID string) bool { + if token == "" || agentID == "" { + return false + } + parts := strings.Split(token, ".") + if len(parts) != 2 { + return false + } + payloadB64, sigB64 := parts[0], parts[1] + + received, err := base64.RawURLEncoding.DecodeString(sigB64) + if err != nil { + return false + } + expected := m.signBytes(payloadB64) + if subtle.ConstantTimeCompare(expected, received) != 1 { + return false + } + + payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadB64) + if err != nil { + return false + } + var payload struct { + AgentID string `json:"agent_id"` + Exp int64 `json:"exp"` + Typ string `json:"typ"` + } + if err := json.Unmarshal(payloadBytes, &payload); err != nil { + return false + } + if payload.Typ != runTokenType { + return false + } + if payload.AgentID != agentID { + return false + } + if m.now().Unix() > payload.Exp { + return false + } + return true +} + +func (m *RunTokenManager) sign(payloadB64 string) string { + return base64.RawURLEncoding.EncodeToString(m.signBytes(payloadB64)) +} + +func (m *RunTokenManager) signBytes(payloadB64 string) []byte { + mac := hmac.New(sha256.New, m.key) + mac.Write([]byte(payloadB64)) + return mac.Sum(nil) +} diff --git a/remote-bob/apiserver/internal/api/run_token_test.go b/remote-bob/apiserver/internal/api/run_token_test.go new file mode 100644 index 00000000..50115555 --- /dev/null +++ b/remote-bob/apiserver/internal/api/run_token_test.go @@ -0,0 +1,144 @@ +package api + +import ( + "strings" + "testing" + "time" +) + +func testRunKey() []byte { + // 32-byte key for HMAC-SHA256. + return []byte("0123456789abcdef0123456789abcdef") +} + +func TestRunToken_IssueAndValidate(t *testing.T) { + mgr := NewRunTokenManager(testRunKey(), 24*time.Hour) + token, err := mgr.Issue("agent-1") + if err != nil { + t.Fatalf("Issue() error: %v", err) + } + if token == "" { + t.Fatal("Issue() returned empty token") + } + if !mgr.Validate(token, "agent-1") { + t.Error("Validate() should accept a freshly issued token for its agent") + } +} + +func TestRunToken_BoundToAgent(t *testing.T) { + mgr := NewRunTokenManager(testRunKey(), 24*time.Hour) + token, err := mgr.Issue("agent-1") + if err != nil { + t.Fatalf("Issue() error: %v", err) + } + if mgr.Validate(token, "agent-2") { + t.Error("cross-agent use of a run token must be rejected") + } + if mgr.Validate(token, "") { + t.Error("empty agent ID must be rejected") + } +} + +func TestRunToken_InvalidTokensRejected(t *testing.T) { + mgr := NewRunTokenManager(testRunKey(), 24*time.Hour) + token, _ := mgr.Issue("agent-1") + + invalid := []string{ + "", + "garbage", + "not-a-token", + "a.b.c", // wrong number of parts + strings.Replace(token, token[:4], "zzzz", 1), // tampered payload + } + for _, tok := range invalid { + if mgr.Validate(tok, "agent-1") { + t.Errorf("Validate(%q) should be rejected", tok) + } + } +} + +func TestRunToken_TamperedSignatureRejected(t *testing.T) { + mgr := NewRunTokenManager(testRunKey(), 24*time.Hour) + token, _ := mgr.Issue("agent-1") + + // Flip a character in the signature portion. + parts := strings.Split(token, ".") + if len(parts) != 2 { + t.Fatalf("unexpected token format: %q", token) + } + sig := parts[1] + flipped := "A" + sig[1:] + tampered := parts[0] + "." + flipped + if mgr.Validate(tampered, "agent-1") { + t.Error("tampered signature must be rejected") + } +} + +func TestRunToken_Expiry(t *testing.T) { + now := time.Now() + mgr := NewRunTokenManager(testRunKey(), 24*time.Hour) + mgr.now = func() time.Time { return now } + + token, err := mgr.Issue("agent-1") + if err != nil { + t.Fatalf("Issue() error: %v", err) + } + + // Advance past the 24h TTL. + mgr.now = func() time.Time { return now.Add(24*time.Hour + time.Second) } + if mgr.Validate(token, "agent-1") { + t.Error("expired run token must be rejected") + } +} + +func TestRunToken_SurvivesRestart(t *testing.T) { + // A new manager with the same key (simulating an apiserver restart) must + // validate tokens issued before the restart. + mgr1 := NewRunTokenManager(testRunKey(), 24*time.Hour) + token, err := mgr1.Issue("agent-1") + if err != nil { + t.Fatalf("Issue() error: %v", err) + } + + mgr2 := NewRunTokenManager(testRunKey(), 24*time.Hour) + if !mgr2.Validate(token, "agent-1") { + t.Error("run token must survive restart (stateless HMAC with stable key)") + } + if mgr2.Validate(token, "agent-2") { + t.Error("run token must stay bound to its agent across restart") + } +} + +func TestRunToken_ReusableWithinTTL(t *testing.T) { + mgr := NewRunTokenManager(testRunKey(), 24*time.Hour) + token, _ := mgr.Issue("agent-1") + + // Stateless tokens are reusable: validation does not consume them. + for i := 0; i < 5; i++ { + if !mgr.Validate(token, "agent-1") { + t.Fatalf("Validate() iteration %d should succeed (reusable)", i) + } + } +} + +func TestRunToken_NoKeyFailsClosed(t *testing.T) { + mgr := NewRunTokenManager(nil, 24*time.Hour) + if _, err := mgr.Issue("agent-1"); err == nil { + t.Error("Issue() without a signing key must fail") + } + if mgr.Validate("anything.anything", "agent-1") { + t.Error("Validate() without a signing key must fail closed") + } +} + +func TestRunToken_TypeSeparation(t *testing.T) { + // A WS token (random hex) must never validate as a run token. + mgr := NewRunTokenManager(testRunKey(), 24*time.Hour) + wsToken, err := randomToken() + if err != nil { + t.Fatalf("randomToken() error: %v", err) + } + if mgr.Validate(wsToken, "agent-1") { + t.Error("a WS token must not validate as a run token") + } +} diff --git a/remote-bob/apiserver/internal/api/server.go b/remote-bob/apiserver/internal/api/server.go new file mode 100644 index 00000000..61b0c0f4 --- /dev/null +++ b/remote-bob/apiserver/internal/api/server.go @@ -0,0 +1,492 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/CodeEngine/remote-bob-apiserver/internal/log" + "github.com/CodeEngine/remote-bob-apiserver/internal/ws" +) + +const ( + defaultWSTokenTTL = 60 * time.Second + defaultRunTokenTTL = 24 * time.Hour + defaultRelayTokenTTL = 60 * time.Second + defaultRelayOpenWait = 10 * time.Second +) + +// Server is the apiserver: a thin authenticated relay with an in-memory agent +// registry and token stores. It has no session management and no persistence. +type Server struct { + basicAuth *BasicAuth + wsTokens *WSTokenStore + runTokens *RunTokenManager + relayTokens *RelayTokenStore + agentRegistry *AgentRegistry + relays *relayManager + wsTokenTTL time.Duration + relayOpenTimeout time.Duration + // shuttingDown is set to 1 atomically when a DELETE /agents/{id} is + // processed. New browser WS connections and logins are rejected with 503 + // so the browser shows "shutting down" rather than reconnecting. + shuttingDown atomic.Int32 + // shutdownCh is closed by MarkShuttingDown so main.go can select on it + // alongside the OS signal channel. Using a channel (rather than SIGTERM) + // ensures the process stays alive until all in-flight 4001 close frames + // have been delivered to job-agents before httpServer.Shutdown runs. + shutdownCh chan struct{} + shutdownOnce sync.Once + // agentWG tracks active handleAgentWS goroutines. WaitAgentsDone blocks + // until all of them have returned, ensuring every queued 4001 close frame + // has been sent before the process exits. + agentWG sync.WaitGroup +} + +// Config holds server configuration. +type Config struct { + GatewayPassword string + RunTokenKey []byte + WSTokenTTL time.Duration + RunTokenTTL time.Duration + RelayTokenTTL time.Duration + RelayOpenTimeout time.Duration +} + +// NewServer creates a new apiserver. +func NewServer(cfg Config) *Server { + if cfg.WSTokenTTL == 0 { + cfg.WSTokenTTL = defaultWSTokenTTL + } + if cfg.RunTokenTTL == 0 { + cfg.RunTokenTTL = defaultRunTokenTTL + } + if cfg.RelayTokenTTL == 0 { + cfg.RelayTokenTTL = defaultRelayTokenTTL + } + if cfg.RelayOpenTimeout == 0 { + cfg.RelayOpenTimeout = defaultRelayOpenWait + } + + relayTokenStore := NewRelayTokenStore(cfg.RelayTokenTTL) + return &Server{ + basicAuth: NewBasicAuth(cfg.GatewayPassword), + wsTokens: NewWSTokenStore(cfg.WSTokenTTL), + runTokens: NewRunTokenManager(cfg.RunTokenKey, cfg.RunTokenTTL), + relayTokens: relayTokenStore, + agentRegistry: NewAgentRegistry(), + relays: newRelayManager(relayTokenStore.Revoke), + wsTokenTTL: cfg.WSTokenTTL, + relayOpenTimeout: cfg.RelayOpenTimeout, + shutdownCh: make(chan struct{}), + } +} + +// Shutdown stops background goroutines and closes agent connections and +// active relays. It also marks the server as shutting down so any concurrent +// login or browser-WS requests during the graceful-shutdown window get a +// 503 "shutting_down" response instead of looping on "Connection failed". +func (s *Server) Shutdown() { + s.shuttingDown.Store(1) + s.wsTokens.Stop() + s.relayTokens.Stop() + s.relays.closeAll() + s.agentRegistry.Close() +} + +// AgentRegistry exposes the registry (used by tests and the relay feature). +func (s *Server) AgentRegistry() *AgentRegistry { + return s.agentRegistry +} + +// MarkShuttingDown marks the server as shutting down. Any subsequent login +// or browser-WS request returns 503 "shutting_down". It also closes +// ShutdownCh so main.go can begin the graceful-shutdown sequence without +// depending on SIGTERM delivery timing. +func (s *Server) MarkShuttingDown() { + s.shuttingDown.Store(1) + s.shutdownOnce.Do(func() { close(s.shutdownCh) }) +} + +// ShutdownCh returns a channel that is closed when MarkShuttingDown is called. +// main.go selects on this alongside the OS signal channel so it can start +// graceful shutdown as soon as the last agent disconnects, without relying on +// SIGTERM being delivered and processed before the 4001 close frames are sent. +func (s *Server) ShutdownCh() <-chan struct{} { + return s.shutdownCh +} + +// WaitAgentsDone blocks until all active handleAgentWS goroutines have +// returned. Call this after ShutdownCh fires and before httpServer.Shutdown so +// every queued 4001 close frame has been flushed to the job-agent. +func (s *Server) WaitAgentsDone() { + s.agentWG.Wait() +} + +// RegisterRoutes registers all HTTP and WebSocket routes. +func (s *Server) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/auth/login", s.handleLogin) + mux.HandleFunc("/auth/runs", s.handleRuns) + mux.HandleFunc("/agents", s.handleAgents) + mux.HandleFunc("/agents/", s.handleAgentByID) + mux.HandleFunc("/healthz", s.handleHealth) + mux.HandleFunc("/readyz", s.handleHealth) + mux.HandleFunc("/ws/agent", s.handleAgentWS) + mux.HandleFunc("/ws/browser", s.handleBrowserWS) + mux.HandleFunc("/ws/relay", s.handleRelayWS) +} + +// handleLogin issues a 60s single-use WS token in exchange for valid Basic +// credentials. +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if s.shuttingDown.Load() != 0 { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "shutting_down", + "message": "Session is shutting down", + }) + return + } + if !s.basicAuth.Check(w, r) { + return + } + token, err := s.wsTokens.Issue() + if err != nil { + log.Error("ws_token_issue_failed", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "token_issue_failed", + }) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "token": token, + "expires_in": int(s.wsTokenTTL.Seconds()), + }) +} + +// handleRuns issues a stateless HMAC run token bound to the requested agent. +func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if !s.basicAuth.Check(w, r) { + return + } + agentID := r.URL.Query().Get("agent") + if agentID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "agent_required", + "message": "agent query parameter is required", + }) + return + } + token, err := s.runTokens.Issue(agentID) + if err != nil { + log.Error("run_token_issue_failed", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "token_issue_failed", + }) + return + } + writeJSON(w, http.StatusOK, map[string]string{ + "run_token": token, + }) +} + +// handleAgents lists registered agents (Basic auth required). +func (s *Server) handleAgents(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if !s.basicAuth.Check(w, r) { + return + } + writeJSON(w, http.StatusOK, s.agentRegistry.List()) +} + +// handleAgentByID handles requests on /agents/{id}. +// +// GET /agents/{id} — check whether the agent is registered (no auth required +// since it reveals no sensitive information, only existence). +// DELETE /agents/{id} — terminate the agent (Basic auth required). +func (s *Server) handleAgentByID(w http.ResponseWriter, r *http.Request) { + agentID := strings.TrimPrefix(r.URL.Path, "/agents/") + if agentID == "" { + http.Error(w, "agent id required", http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + // Browser uses this to probe whether the agent is still alive after a + // failed /ws/browser connect. Returns 200 if found, 404 if not found, + // 503 if the server is shutting down. + if s.shuttingDown.Load() != 0 { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "shutting_down", + "message": "Session is shutting down", + }) + return + } + if !s.agentRegistry.Has(agentID) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + + case http.MethodDelete: + if !s.basicAuth.Check(w, r) { + return + } + // Mark server as shutting down before disconnecting so any concurrent + // browser reconnect attempts see 503 immediately. + s.MarkShuttingDown() + found := s.agentRegistry.Disconnect(agentID) + if !found { + // Agent already gone — still stay in shutting-down state. + writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"}) + return + } + w.WriteHeader(http.StatusNoContent) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +// handleHealth is the public liveness/readiness probe. +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// handleAgentWS is the agent control connection. It requires a valid run +// token bound to the agent ID in the Authorization: Bearer header. +func (s *Server) handleAgentWS(w http.ResponseWriter, r *http.Request) { + agentID := r.URL.Query().Get("agent") + if agentID == "" { + http.Error(w, "agent is required", http.StatusBadRequest) + return + } + token := bearerToken(r.Header.Get("Authorization")) + if token == "" { + http.Error(w, "Authorization: Bearer header is required", http.StatusUnauthorized) + return + } + if !s.runTokens.Validate(token, agentID) { + http.Error(w, "Invalid run token", http.StatusUnauthorized) + return + } + + conn, err := ws.Upgrade(w, r) + if err != nil { + log.Error("agent_ws_upgrade_failed", err, "agent_id", agentID) + return + } + + // Track this goroutine so WaitAgentsDone can block until the 4001 close + // frame has been sent and the connection fully closed. + s.agentWG.Add(1) + defer s.agentWG.Done() + + terminateCh := s.agentRegistry.Register(agentID, conn) + defer s.agentRegistry.Unregister(agentID, conn) + // If this control connection is replaced or drops, every relay opened on + // it is torn down (control-connection replacement semantics). + defer s.relays.closeAgent(agentID, conn) + log.Info("agent_ws_connected", "agent_id", agentID) + + // Watch for deliberate termination (DELETE /agents/{id}). When the + // terminateCh is closed, send WS close code 4001 to the job-agent and + // close the connection to unblock the read loop below. The write goes + // through the relayManager's per-connection mutex to serialise it with + // any concurrent relay-open writes on the same connection. + go func() { + <-terminateCh + closeFrame := buildCloseFrame(closeCodeAgentTerminated, "terminated by request") + s.relays.sendRawFrame(conn, ws.MsgClose, closeFrame) + conn.Close() + }() + + for { + f, err := conn.ReadFrame() + if err != nil { + return + } + var ctrl controlMessage + if err := json.Unmarshal(f.Payload, &ctrl); err != nil { + log.Warn("agent_ws_invalid_control_message", "agent_id", agentID) + continue + } + switch ctrl.Type { + case "register": + s.agentRegistry.SetServices(agentID, ctrl.Services) + log.Info("agent_ws_registered", "agent_id", agentID, "services", len(ctrl.Services)) + case "relay-ready": + // Optional acknowledgement that the agent dialed /ws/relay. + log.Debug("agent_ws_relay_ready", "agent_id", agentID, "relay_id", ctrl.RelayID) + default: + // Unknown control messages are ignored. + } + } +} + +// handleBrowserWS is the browser connection. It requires a valid, unused, +// unexpired WS token and a registered agent+service. On success it issues a +// one-time relay token, sends relay-open over the agent control connection, +// waits (bounded) for the agent's relay dial, then pipes frames opaquely. +func (s *Server) handleBrowserWS(w http.ResponseWriter, r *http.Request) { + if s.shuttingDown.Load() != 0 { + http.Error(w, "Session is shutting down", http.StatusServiceUnavailable) + return + } + token := r.URL.Query().Get("token") + if token == "" { + http.Error(w, "token is required", http.StatusUnauthorized) + return + } + if !s.wsTokens.Consume(token) { + http.Error(w, "Invalid or expired token", http.StatusUnauthorized) + return + } + + agentID := r.URL.Query().Get("agent") + if agentID == "" || !s.agentRegistry.Has(agentID) { + http.Error(w, "Agent not found", http.StatusNotFound) + return + } + service := r.URL.Query().Get("service") + if service == "" || !s.agentRegistry.HasService(agentID, service) { + http.Error(w, "Service not found", http.StatusNotFound) + return + } + + conn, err := ws.Upgrade(w, r) + if err != nil { + log.Error("browser_ws_upgrade_failed", err, "agent_id", agentID, "service", service) + return + } + defer conn.Close() + log.Info("browser_ws_connected", "agent_id", agentID, "service", service) + + // Issue a one-time relay token and register the relay. + relayToken, err := s.relayTokens.Issue() + if err != nil { + log.Error("relay_token_issue_failed", err) + return + } + controlConn := s.agentRegistry.Get(agentID) + entry, err := s.relays.create(agentID, service, conn, relayToken, controlConn) + if err != nil { + log.Error("relay_create_failed", err) + return + } + defer s.relays.close(entry.id) + + // Start the pipe goroutines immediately. The browser→relay direction + // reads from the browser connection, so a browser disconnect before the + // agent dials is detected and tears the relay down (invalidating the + // one-time relay token). + startRelayPipes(entry) + + // Send relay-open over the agent control connection. + openMsg := map[string]interface{}{ + "type": "relay-open", + "relay_id": entry.id, + "service": service, + "relay_token": relayToken, + } + if err := s.relays.sendControl(controlConn, openMsg); err != nil { + log.Warn("relay_open_send_failed", "agent_id", agentID, "relay_id", entry.id) + return + } + + // Wait (bounded) for the agent to dial /ws/relay with the one-time + // token. If it never arrives, the browser gets a clean close. + select { + case <-entry.attached: + case <-entry.done: + return + case <-time.After(s.relayOpenTimeout): + log.Warn("relay_open_timeout", "agent_id", agentID, "relay_id", entry.id) + return + } + + // The relay is established; block until it is torn down (browser or + // agent relay side closed). + <-entry.done + + // Notify the agent so it can close the corresponding upstream + // connection. If the control connection is gone (agent control loss), + // this fails silently — the agent's relay connection close already + // signals it. + _ = s.relays.sendControl(controlConn, map[string]interface{}{ + "type": "relay-close", + "relay_id": entry.id, + }) +} + +// handleRelayWS is the agent relay connection, authenticated by a one-time +// relay token. It attaches to the pending relay and pipes frames opaquely. +func (s *Server) handleRelayWS(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("relayToken") + if token == "" { + http.Error(w, "relayToken is required", http.StatusUnauthorized) + return + } + if !s.relayTokens.Consume(token) { + http.Error(w, "Invalid or expired relay token", http.StatusUnauthorized) + return + } + + conn, err := ws.Upgrade(w, r) + if err != nil { + log.Error("relay_ws_upgrade_failed", err) + return + } + defer conn.Close() + log.Info("relay_ws_connected", "relay_token", token[:8]) + + entry, ok := s.relays.attach(token, conn) + if !ok { + // The browser disconnected or timed out before the agent dialed; + // the token is already consumed, so this late dial is rejected. + log.Warn("relay_attach_failed", "reason", "no pending relay for token") + return + } + + // The pipe goroutines were started by the browser handler; block until + // the relay is torn down. + <-entry.done +} + +// controlMessage is a JSON control message on the agent control connection. +type controlMessage struct { + Type string `json:"type"` + Services []Service `json:"services"` + RelayID string `json:"relay_id"` +} + +// bearerToken extracts the token from an "Authorization: Bearer " +// header, or returns "". +func bearerToken(header string) string { + parts := strings.SplitN(header, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "" + } + return strings.TrimSpace(parts[1]) +} + +// writeJSON writes a JSON response with the given status. +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/remote-bob/apiserver/internal/api/server_test.go b/remote-bob/apiserver/internal/api/server_test.go new file mode 100644 index 00000000..88fb8460 --- /dev/null +++ b/remote-bob/apiserver/internal/api/server_test.go @@ -0,0 +1,633 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +// newTestServer builds a Server wired to a test mux with the middleware chain +// used in production (request logging, CORS, panic recovery). It returns the +// server and the httptest server URL. +func newTestServer(t *testing.T, password string, runKey []byte) (*Server, string) { + t.Helper() + srv := NewServer(Config{ + GatewayPassword: password, + RunTokenKey: runKey, + }) + t.Cleanup(srv.Shutdown) + + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + handler := NewRequestLogger(mux) + handler = NewCORSMiddlewareWithOrigins([]string{"http://localhost:3000"}).Wrap(handler) + handler = NewPanicRecovery(handler) + + ts := httptest.NewServer(handler) + t.Cleanup(ts.Close) + return srv, ts.URL +} + +func doRequest(t *testing.T, method, url string, headers map[string]string) *http.Response { + t.Helper() + req, err := http.NewRequest(method, url, nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + return resp +} + +func decodeBody(t *testing.T, resp *http.Response) map[string]interface{} { + t.Helper() + defer resp.Body.Close() + var body map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + return body +} + +// basicHeader builds a Basic Authorization header value. +func basicHeader(username, password string) string { + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.SetBasicAuth(username, password) + return req.Header.Get("Authorization") +} + +func TestLogin_ValidCredentials(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + resp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + body := decodeBody(t, resp) + token, _ := body["token"].(string) + if token == "" { + t.Fatal("expected non-empty token in response") + } + if expires, ok := body["expires_in"].(float64); !ok || expires != 60 { + t.Errorf("expected expires_in == 60, got %v", body["expires_in"]) + } +} + +func TestLogin_WrongPassword(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + resp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "wrong-password"), + }) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } + body := decodeBody(t, resp) + if _, ok := body["token"]; ok { + t.Error("no token should be returned on failure") + } +} + +func TestLogin_MissingCredentials(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + // No Authorization header. + resp := doRequest(t, http.MethodPost, ts+"/auth/login", nil) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401 for missing header, got %d", resp.StatusCode) + } + resp.Body.Close() + + // Malformed header. + resp = doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": "Basic not-base64!!!", + }) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401 for malformed header, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestLogin_WrongUsername(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + resp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("attacker", "test-password"), + }) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestLogin_ClearErrorMessage(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + resp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "wrong"), + }) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } + body := decodeBody(t, resp) + msg, _ := body["message"].(string) + if msg == "" { + t.Error("expected a clear human-readable error message") + } +} + +func TestLogin_WrongUserAndWrongPassIndistinguishable(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + wrongUser := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("attacker", "test-password"), + }) + wrongUserBody := decodeBody(t, wrongUser) + + wrongPass := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "wrong-password"), + }) + wrongPassBody := decodeBody(t, wrongPass) + + if wrongUser.StatusCode != wrongPass.StatusCode { + t.Errorf("status codes differ: %d vs %d", wrongUser.StatusCode, wrongPass.StatusCode) + } + if wrongUserBody["message"] != wrongPassBody["message"] { + t.Errorf("messages differ: %v vs %v", wrongUserBody["message"], wrongPassBody["message"]) + } +} + +func TestLogin_RepeatedFailuresDoNotCrashServer(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + for i := 0; i < 50; i++ { + resp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "wrong"), + }) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("attempt %d: expected 401, got %d", i, resp.StatusCode) + } + resp.Body.Close() + } + + // A correct login still works. + resp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("correct login after failure burst: expected 200, got %d", resp.StatusCode) + } + resp.Body.Close() + + // Health still responds. + health := doRequest(t, http.MethodGet, ts+"/healthz", nil) + if health.StatusCode != http.StatusOK { + t.Errorf("healthz after failure burst: expected 200, got %d", health.StatusCode) + } + health.Body.Close() +} + +func TestLogin_ConcurrentDistinctTokens(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + const n = 20 + var wg sync.WaitGroup + tokens := make([]string, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + resp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + if resp.StatusCode != http.StatusOK { + t.Errorf("login %d: expected 200, got %d", i, resp.StatusCode) + return + } + body := decodeBody(t, resp) + tokens[i], _ = body["token"].(string) + }(i) + } + wg.Wait() + + seen := make(map[string]bool) + for _, token := range tokens { + if token == "" { + t.Fatal("a concurrent login returned an empty token") + } + if seen[token] { + t.Fatal("concurrent logins returned duplicate tokens") + } + seen[token] = true + } +} + +func TestRuns_ValidCredentials(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + resp := doRequest(t, http.MethodPost, ts+"/auth/runs?agent=agent-1", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + body := decodeBody(t, resp) + runToken, _ := body["run_token"].(string) + if runToken == "" { + t.Fatal("expected non-empty run_token in response") + } +} + +func TestRuns_WrongCredentials(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + resp := doRequest(t, http.MethodPost, ts+"/auth/runs?agent=agent-1", map[string]string{ + "Authorization": basicHeader("admin", "wrong"), + }) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } + body := decodeBody(t, resp) + if _, ok := body["run_token"]; ok { + t.Error("no run_token should be returned on failure") + } +} + +func TestRuns_MissingAgentParam(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + resp := doRequest(t, http.MethodPost, ts+"/auth/runs", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for missing agent, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestAgents_RequiresBasicAuth(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + // No credentials -> 401. + resp := doRequest(t, http.MethodGet, ts+"/agents", nil) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401 without credentials, got %d", resp.StatusCode) + } + resp.Body.Close() + + // Wrong credentials -> 401. + resp = doRequest(t, http.MethodGet, ts+"/agents", map[string]string{ + "Authorization": basicHeader("admin", "wrong"), + }) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401 with wrong credentials, got %d", resp.StatusCode) + } + resp.Body.Close() + + // Valid credentials -> 200 with a JSON array. + resp = doRequest(t, http.MethodGet, ts+"/agents", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 with valid credentials, got %d", resp.StatusCode) + } + defer resp.Body.Close() + var list []map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + t.Fatalf("expected a JSON array: %v", err) + } +} + +func TestHealth_Public(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + for _, path := range []string{"/healthz", "/readyz"} { + resp := doRequest(t, http.MethodGet, ts+path, nil) + if resp.StatusCode != http.StatusOK { + t.Errorf("%s without auth: expected 200, got %d", path, resp.StatusCode) + } + resp.Body.Close() + } +} + +func TestHealth_Stays200UnderConcurrentLoad(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + resp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + if resp.StatusCode != http.StatusOK { + t.Errorf("concurrent login: expected 200, got %d", resp.StatusCode) + } + resp.Body.Close() + }() + } + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for _, path := range []string{"/healthz", "/readyz"} { + resp := doRequest(t, http.MethodGet, ts+path, nil) + if resp.StatusCode != http.StatusOK { + t.Errorf("%s under load: expected 200, got %d", path, resp.StatusCode) + } + resp.Body.Close() + } + }() + } + wg.Wait() +} + +func TestAuthEndpoints_RejectNonPost(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + for _, path := range []string{"/auth/login", "/auth/runs?agent=agent-1"} { + for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete} { + resp := doRequest(t, method, ts+path, map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("%s %s: expected 405, got %d", method, path, resp.StatusCode) + } + resp.Body.Close() + } + } +} + +func TestAuth_FailsClosedWhenPasswordUnset(t *testing.T) { + // Server started without GATEWAY_PASSWORD: login must not grant access. + _, ts := newTestServer(t, "", testRunKey()) + + resp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "anything"), + }) + if resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("expected 503 when GATEWAY_PASSWORD is unset, got %d", resp.StatusCode) + } + body := decodeBody(t, resp) + if _, ok := body["token"]; ok { + t.Error("no token should be issued when auth is not configured") + } +} + +func TestAuth_FailsClosedWhenPasswordEmpty(t *testing.T) { + _, ts := newTestServer(t, "", testRunKey()) + + // Even an empty password must not authenticate. + resp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", ""), + }) + if resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("expected 503 for empty GATEWAY_PASSWORD, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestCORS_PreflightToLoginSucceedsWithoutAuth(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + req, err := http.NewRequest(http.MethodOptions, ts+"/auth/login", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("Origin", "http://localhost:3000") + req.Header.Set("Access-Control-Request-Method", "POST") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("preflight expected 2xx, got %d", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "http://localhost:3000" { + t.Errorf("Access-Control-Allow-Origin = %q", got) + } + allowHeaders := resp.Header.Get("Access-Control-Allow-Headers") + if !strings.Contains(allowHeaders, "Authorization") || !strings.Contains(allowHeaders, "Content-Type") { + t.Errorf("Access-Control-Allow-Headers = %q, want Authorization and Content-Type", allowHeaders) + } +} + +func TestCORS_DisallowedOriginRejected(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + req, err := http.NewRequest(http.MethodGet, ts+"/healthz", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("Origin", "http://evil.example.com") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + defer resp.Body.Close() + + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("Access-Control-Allow-Origin = %q, want empty for disallowed origin", got) + } +} + +func TestPanicRecovery_Returns500(t *testing.T) { + panicking := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("boom") + }) + handler := NewPanicRecovery(panicking) + + req := httptest.NewRequest(http.MethodGet, "/boom", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 after panic, got %d", w.Code) + } +} + +func TestAgentByID_GetNotFound(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + // Unknown agent → 404. + resp := doRequest(t, http.MethodGet, ts+"/agents/no-such-agent", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 for unknown agent, got %d", resp.StatusCode) + } + body := decodeBody(t, resp) + if _, ok := body["error"]; !ok { + t.Error("expected error field in 404 body") + } +} + +func TestAgentByID_GetFound(t *testing.T) { + srv, ts := newTestServer(t, "test-password", testRunKey()) + + // Register an agent with a service. + conn := newTestWSConn(t) + srv.AgentRegistry().Register("agent-x", conn) + srv.AgentRegistry().SetServices("agent-x", []Service{{Name: "ttyd"}}) + + resp := doRequest(t, http.MethodGet, ts+"/agents/agent-x", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 for registered agent, got %d", resp.StatusCode) + } + body := decodeBody(t, resp) + if body["status"] != "ok" { + t.Errorf("expected status=ok, got %v", body["status"]) + } +} + +func TestAgentByID_GetShuttingDown(t *testing.T) { + srv, ts := newTestServer(t, "test-password", testRunKey()) + + // Register and immediately mark shutting down. + conn := newTestWSConn(t) + srv.AgentRegistry().Register("agent-y", conn) + srv.AgentRegistry().SetServices("agent-y", []Service{{Name: "ttyd"}}) + srv.MarkShuttingDown() + + resp := doRequest(t, http.MethodGet, ts+"/agents/agent-y", nil) + if resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("expected 503 when shutting down, got %d", resp.StatusCode) + } + body := decodeBody(t, resp) + if body["error"] != "shutting_down" { + t.Errorf("expected error=shutting_down, got %v", body["error"]) + } +} + +func TestAgentByID_GetRequiresNoAuth(t *testing.T) { + srv, ts := newTestServer(t, "test-password", testRunKey()) + + conn := newTestWSConn(t) + srv.AgentRegistry().Register("agent-z", conn) + srv.AgentRegistry().SetServices("agent-z", []Service{{Name: "ttyd"}}) + + // No Authorization header — should still return 200. + resp := doRequest(t, http.MethodGet, ts+"/agents/agent-z", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /agents/{id} should not require auth, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestAgentByID_DeleteRequiresAuth(t *testing.T) { + srv, ts := newTestServer(t, "test-password", testRunKey()) + + conn := newTestWSConn(t) + srv.AgentRegistry().Register("agent-d", conn) + srv.AgentRegistry().SetServices("agent-d", []Service{{Name: "ttyd"}}) + + // No credentials. + resp := doRequest(t, http.MethodDelete, ts+"/agents/agent-d", nil) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("DELETE without auth: expected 401, got %d", resp.StatusCode) + } + resp.Body.Close() + + // Wrong credentials. + resp = doRequest(t, http.MethodDelete, ts+"/agents/agent-d", map[string]string{ + "Authorization": basicHeader("admin", "wrong"), + }) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("DELETE with wrong auth: expected 401, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestAgentByID_MethodNotAllowed(t *testing.T) { + _, ts := newTestServer(t, "test-password", testRunKey()) + + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch} { + resp := doRequest(t, method, ts+"/agents/some-id", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("%s /agents/id: expected 405, got %d", method, resp.StatusCode) + } + resp.Body.Close() + } +} + +func TestLogin_ShuttingDown_AfterIdleTimeout(t *testing.T) { + // Simulate idle timeout: register agent, fire onEmpty → MarkShuttingDown. + srv, ts := newTestServer(t, "test-password", testRunKey()) + + fired := make(chan struct{}) + srv.AgentRegistry().SetOnEmpty(func() { + srv.MarkShuttingDown() + close(fired) + }) + + conn := newTestWSConn(t) + srv.AgentRegistry().Register("agent-idle", conn) + srv.AgentRegistry().SetServices("agent-idle", []Service{{Name: "ttyd"}}) + // Unregister simulates the agent disconnecting after idle timeout. + srv.AgentRegistry().Unregister("agent-idle", conn) + + select { + case <-fired: + case <-time.After(time.Second): + t.Fatal("onEmpty callback not fired within 1s") + } + + // Login must now return 503 shutting_down. + resp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + if resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("expected 503 after idle timeout, got %d", resp.StatusCode) + } + body := decodeBody(t, resp) + if body["error"] != "shutting_down" { + t.Errorf("expected error=shutting_down, got %v", body["error"]) + } +} + +func TestAgentByID_GetGoneAfterIdleTimeout(t *testing.T) { + // Simulate the full idle-timeout flow: browser probes GET /agents/{id} + // after reconnect fails — must get 404 or 503, never 200 with a stale agent. + srv, ts := newTestServer(t, "test-password", testRunKey()) + + fired := make(chan struct{}) + srv.AgentRegistry().SetOnEmpty(func() { + srv.MarkShuttingDown() + close(fired) + }) + + conn := newTestWSConn(t) + srv.AgentRegistry().Register("agent-idle2", conn) + srv.AgentRegistry().SetServices("agent-idle2", []Service{{Name: "ttyd"}}) + srv.AgentRegistry().Unregister("agent-idle2", conn) + + select { + case <-fired: + case <-time.After(time.Second): + t.Fatal("onEmpty not fired") + } + + // GET /agents/{id} must return 503 (shutting_down) — not 200. + resp := doRequest(t, http.MethodGet, ts+"/agents/agent-idle2", nil) + if resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("expected 503 after idle timeout, got %d", resp.StatusCode) + } + body := decodeBody(t, resp) + if body["error"] != "shutting_down" { + t.Errorf("expected error=shutting_down, got %v", body["error"]) + } +} diff --git a/remote-bob/apiserver/internal/api/token_store.go b/remote-bob/apiserver/internal/api/token_store.go new file mode 100644 index 00000000..315cdb46 --- /dev/null +++ b/remote-bob/apiserver/internal/api/token_store.go @@ -0,0 +1,130 @@ +package api + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "sync" + "time" +) + +// wsTokenEntry is a single issued WS token. +type wsTokenEntry struct { + expiresAt time.Time + used bool +} + +// WSTokenStore issues and validates single-use, short-lived WS tokens. +// Tokens are stored in memory keyed by their SHA-256 hash; a restart +// invalidates all outstanding tokens. Consumption is atomic, so exactly one +// of N concurrent consumers of the same token succeeds. +type WSTokenStore struct { + mu sync.Mutex + tokens map[string]wsTokenEntry + ttl time.Duration + now func() time.Time + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup +} + +// NewWSTokenStore creates a token store with the given TTL. +func NewWSTokenStore(ttl time.Duration) *WSTokenStore { + s := &WSTokenStore{ + tokens: make(map[string]wsTokenEntry), + ttl: ttl, + now: time.Now, + stopCh: make(chan struct{}), + } + s.wg.Add(1) + go s.sweepLoop() + return s +} + +// Issue generates a new single-use token valid for the store's TTL. +func (s *WSTokenStore) Issue() (string, error) { + token, err := randomToken() + if err != nil { + return "", err + } + s.mu.Lock() + defer s.mu.Unlock() + s.tokens[hashToken(token)] = wsTokenEntry{ + expiresAt: s.now().Add(s.ttl), + } + return token, nil +} + +// Consume atomically validates and consumes a token. It returns true exactly +// once for any given token: a second concurrent or later call returns false. +// Expired or unknown tokens return false. +func (s *WSTokenStore) Consume(token string) bool { + if token == "" { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + key := hashToken(token) + entry, ok := s.tokens[key] + if !ok { + return false + } + if entry.used { + return false + } + if s.now().After(entry.expiresAt) { + delete(s.tokens, key) + return false + } + entry.used = true + s.tokens[key] = entry + return true +} + +func (s *WSTokenStore) sweepLoop() { + defer s.wg.Done() + ticker := time.NewTicker(s.ttl) + defer ticker.Stop() + for { + select { + case <-ticker.C: + s.sweep() + case <-s.stopCh: + return + } + } +} + +func (s *WSTokenStore) sweep() { + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + for k, e := range s.tokens { + if now.After(e.expiresAt) { + delete(s.tokens, k) + } + } +} + +// Stop stops the background sweeper. +func (s *WSTokenStore) Stop() { + s.stopOnce.Do(func() { + close(s.stopCh) + s.wg.Wait() + }) +} + +// randomToken returns a 32-byte CSPRNG token hex-encoded (256 bits of +// entropy, well above the 128-bit minimum). +func randomToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} diff --git a/remote-bob/apiserver/internal/api/token_store_test.go b/remote-bob/apiserver/internal/api/token_store_test.go new file mode 100644 index 00000000..f281f3e9 --- /dev/null +++ b/remote-bob/apiserver/internal/api/token_store_test.go @@ -0,0 +1,186 @@ +package api + +import ( + "sync" + "testing" + "time" +) + +func TestWSTokenStore_IssueAndConsume(t *testing.T) { + store := NewWSTokenStore(60 * time.Second) + defer store.Stop() + + token, err := store.Issue() + if err != nil { + t.Fatalf("Issue() error: %v", err) + } + if token == "" { + t.Fatal("Issue() returned empty token") + } + if len(token) < 32 { + t.Errorf("token too short: %d chars (want >= 32)", len(token)) + } + + if !store.Consume(token) { + t.Error("first Consume of a fresh token should succeed") + } + if store.Consume(token) { + t.Error("second Consume of the same token should fail (single-use)") + } +} + +func TestWSTokenStore_UnknownTokenRejected(t *testing.T) { + store := NewWSTokenStore(60 * time.Second) + defer store.Stop() + + if store.Consume("garbage-token") { + t.Error("Consume of an unknown token should fail") + } + if store.Consume("") { + t.Error("Consume of an empty token should fail") + } +} + +func TestWSTokenStore_Expiry(t *testing.T) { + now := time.Now() + store := NewWSTokenStore(60 * time.Second) + defer store.Stop() + store.now = func() time.Time { return now } + + token, err := store.Issue() + if err != nil { + t.Fatalf("Issue() error: %v", err) + } + + // Advance past the TTL. + store.now = func() time.Time { return now.Add(61 * time.Second) } + if store.Consume(token) { + t.Error("Consume of an expired token should fail") + } +} + +func TestWSTokenStore_ExpiryBoundary(t *testing.T) { + now := time.Now() + store := NewWSTokenStore(60 * time.Second) + defer store.Stop() + store.now = func() time.Time { return now } + + token, err := store.Issue() + if err != nil { + t.Fatalf("Issue() error: %v", err) + } + + // Exactly at the TTL boundary the token is still valid. + store.now = func() time.Time { return now.Add(60 * time.Second) } + if !store.Consume(token) { + t.Error("Consume at the exact TTL boundary should succeed") + } +} + +func TestWSTokenStore_ConcurrentConsumeRaceSafe(t *testing.T) { + store := NewWSTokenStore(60 * time.Second) + defer store.Stop() + + token, err := store.Issue() + if err != nil { + t.Fatalf("Issue() error: %v", err) + } + + const n = 20 + var wg sync.WaitGroup + results := make([]bool, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + results[i] = store.Consume(token) + }(i) + } + wg.Wait() + + successes := 0 + for _, ok := range results { + if ok { + successes++ + } + } + if successes != 1 { + t.Errorf("exactly one concurrent Consume should succeed, got %d", successes) + } +} + +func TestWSTokenStore_ConcurrentIssueDistinct(t *testing.T) { + store := NewWSTokenStore(60 * time.Second) + defer store.Stop() + + const n = 20 + var wg sync.WaitGroup + tokens := make([]string, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + token, err := store.Issue() + if err != nil { + t.Errorf("Issue() error: %v", err) + return + } + tokens[i] = token + }(i) + } + wg.Wait() + + seen := make(map[string]bool) + for _, token := range tokens { + if token == "" { + t.Fatal("Issue() returned an empty token") + } + if seen[token] { + t.Fatal("concurrent Issue() returned duplicate tokens") + } + seen[token] = true + } + + // Each token is usable exactly once. + for _, token := range tokens { + if !store.Consume(token) { + t.Errorf("token %q should be consumable once", token) + } + if store.Consume(token) { + t.Errorf("token %q should not be consumable twice", token) + } + } +} + +func TestWSTokenStore_RestartInvalidates(t *testing.T) { + // A restart creates a fresh store; previously issued tokens are gone. + store1 := NewWSTokenStore(60 * time.Second) + token, err := store1.Issue() + if err != nil { + t.Fatalf("Issue() error: %v", err) + } + store1.Stop() + + store2 := NewWSTokenStore(60 * time.Second) + defer store2.Stop() + if store2.Consume(token) { + t.Error("a token issued before restart must be rejected after restart") + } +} + +func TestWSTokenStore_Entropy(t *testing.T) { + // Tokens must be high-entropy (>= 128 bits). 32 random bytes hex-encoded + // is 256 bits; verify the raw length and that tokens differ. + store := NewWSTokenStore(60 * time.Second) + defer store.Stop() + + a, _ := store.Issue() + b, _ := store.Issue() + if a == b { + t.Fatal("two issued tokens must differ") + } + // 32 bytes -> 64 hex chars. + if len(a) != 64 || len(b) != 64 { + t.Errorf("expected 64-char hex tokens, got %d and %d", len(a), len(b)) + } +} diff --git a/remote-bob/apiserver/internal/api/ws_auth_test.go b/remote-bob/apiserver/internal/api/ws_auth_test.go new file mode 100644 index 00000000..cf073bc2 --- /dev/null +++ b/remote-bob/apiserver/internal/api/ws_auth_test.go @@ -0,0 +1,615 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/CodeEngine/remote-bob-apiserver/internal/ws" +) + +// newWSServer builds a Server with only the WS routes registered (no +// middleware) and returns the server plus a ws:// base URL. +func newWSServer(t *testing.T, password string, runKey []byte) (*Server, string) { + t.Helper() + srv := NewServer(Config{ + GatewayPassword: password, + RunTokenKey: runKey, + }) + t.Cleanup(srv.Shutdown) + + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + return srv, "ws" + strings.TrimPrefix(ts.URL, "http") +} + +// dialWS attempts a WebSocket dial and returns the connection, the HTTP +// response (for rejected-upgrade checks), and any error. +// When the server rejects the upgrade, conn is nil and resp carries the HTTP status. +func dialWS(t *testing.T, rawURL string, headers map[string]string) (*ws.Conn, *http.Response, error) { + t.Helper() + res, err := ws.DialFull(rawURL, ws.DialHeader(headers)) + return res.Conn, res.Response, err +} + +// registerTestAgent dials /ws/agent with a valid run token and sends a +// register control message. It returns the control connection. +// A background goroutine reads from the conn so that Done() fires when the +// server side closes the connection (e.g. on replacement). +func registerTestAgent(t *testing.T, wsBase, runToken, agentID string, services []Service) *ws.Conn { + t.Helper() + conn, resp, err := dialWS(t, wsBase+"/ws/agent?agent="+agentID, map[string]string{ + "Authorization": "Bearer " + runToken, + }) + if err != nil { + t.Fatalf("agent dial failed: %v (resp %v)", err, resp) + } + msg, _ := json.Marshal(map[string]interface{}{ + "type": "register", + "services": services, + }) + if err := conn.WriteFrame(ws.MsgText, msg); err != nil { + t.Fatalf("register write failed: %v", err) + } + return conn +} + +func TestAgentWS_NoAuthorizationRejected(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + + _, resp, err := dialWS(t, wsBase+"/ws/agent?agent=agent-1", nil) + if err == nil { + t.Fatal("expected dial to fail without Authorization header") + } + if resp == nil || resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401, got %v", resp) + } +} + +func TestAgentWS_InvalidRunTokenRejected(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + + _, resp, err := dialWS(t, wsBase+"/ws/agent?agent=agent-1", map[string]string{ + "Authorization": "Bearer garbage-token", + }) + if err == nil { + t.Fatal("expected dial to fail with invalid run token") + } + if resp == nil || resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401, got %v", resp) + } +} + +func TestAgentWS_ValidRunTokenRegisters(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + + // Issue a run token via the REST endpoint. + ts := "http" + strings.TrimPrefix(wsBase, "ws") + resp := doRequest(t, http.MethodPost, ts+"/auth/runs?agent=agent-1", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + body := decodeBody(t, resp) + runToken, _ := body["run_token"].(string) + if runToken == "" { + t.Fatal("no run token issued") + } + + conn := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd", Upstream: "ws://127.0.0.1:7080"}}) + defer conn.Close() + + // The register control message is processed asynchronously; poll until + // the agent appears in GET /agents. + var agents []AgentInfo + deadline := time.Now().Add(2 * time.Second) + for { + agentsResp := doRequest(t, http.MethodGet, ts+"/agents", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + agents = nil + if err := json.NewDecoder(agentsResp.Body).Decode(&agents); err != nil { + agentsResp.Body.Close() + t.Fatalf("decode agents: %v", err) + } + agentsResp.Body.Close() + found := false + for _, a := range agents { + if a.AgentID == "agent-1" && len(a.Services) == 1 { + found = true + } + } + if found { + break + } + if time.Now().After(deadline) { + t.Fatal("agent-1 did not appear in GET /agents with services in time") + } + time.Sleep(10 * time.Millisecond) + } + + found := false + for _, a := range agents { + if a.AgentID == "agent-1" { + found = true + if a.Status != "ready" { + t.Errorf("agent status = %q, want ready", a.Status) + } + if len(a.Services) != 1 || a.Services[0] != "ttyd" { + t.Errorf("agent services = %v, want [ttyd]", a.Services) + } + } + } + if !found { + t.Error("agent-1 not listed in GET /agents") + } +} + +func TestAgentWS_CrossAgentUseRejected(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + + ts := "http" + strings.TrimPrefix(wsBase, "ws") + resp := doRequest(t, http.MethodPost, ts+"/auth/runs?agent=agent-A", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + body := decodeBody(t, resp) + runToken, _ := body["run_token"].(string) + if runToken == "" { + t.Fatal("no run token issued") + } + + // Use agent A's token with agent=B: must be rejected and B not registered. + _, dialResp, err := dialWS(t, wsBase+"/ws/agent?agent=agent-B", map[string]string{ + "Authorization": "Bearer " + runToken, + }) + if err == nil { + t.Fatal("expected cross-agent dial to fail") + } + if dialResp == nil || dialResp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401 for cross-agent use, got %v", dialResp) + } + + agentsResp := doRequest(t, http.MethodGet, ts+"/agents", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + defer agentsResp.Body.Close() + var agents []AgentInfo + if err := json.NewDecoder(agentsResp.Body).Decode(&agents); err != nil { + t.Fatalf("decode agents: %v", err) + } + for _, a := range agents { + if a.AgentID == "agent-B" { + t.Error("agent-B must not be registered after cross-agent token use") + } + } +} + +func TestAgentWS_SecondConnectionReplacesFirst(t *testing.T) { + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + + ts := "http" + strings.TrimPrefix(wsBase, "ws") + resp := doRequest(t, http.MethodPost, ts+"/auth/runs?agent=agent-1", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + body := decodeBody(t, resp) + runToken, _ := body["run_token"].(string) + + conn1 := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer conn1.Close() + + // Second connection with the same agent ID replaces the first. + conn2 := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer conn2.Close() + + // The first connection should be closed by the registry — Done fires. + waitConnDone(t, conn1, "first control connection should be closed after replacement") + + // The agent is registered exactly once. + if srv.AgentRegistry().Count() != 1 { + t.Errorf("expected exactly 1 registered agent, got %d", srv.AgentRegistry().Count()) + } +} + +func TestAgentWS_ControlDropUnregisters(t *testing.T) { + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + + ts := "http" + strings.TrimPrefix(wsBase, "ws") + resp := doRequest(t, http.MethodPost, ts+"/auth/runs?agent=agent-1", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + body := decodeBody(t, resp) + runToken, _ := body["run_token"].(string) + + conn := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + if !srv.AgentRegistry().Has("agent-1") { + t.Fatal("agent should be registered") + } + conn.Close() + + // Wait for the handler to unregister. + deadline := time.Now().Add(2 * time.Second) + for srv.AgentRegistry().Has("agent-1") && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if srv.AgentRegistry().Has("agent-1") { + t.Error("agent should be unregistered after control connection close") + } +} + +func TestBrowserWS_NoTokenRejected(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + + _, resp, err := dialWS(t, wsBase+"/ws/browser?agent=agent-1&service=ttyd", nil) + if err == nil { + t.Fatal("expected dial to fail without token") + } + if resp == nil || resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401, got %v", resp) + } +} + +func TestBrowserWS_InvalidTokenRejected(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + + _, resp, err := dialWS(t, wsBase+"/ws/browser?token=garbage&agent=agent-1&service=ttyd", nil) + if err == nil { + t.Fatal("expected dial to fail with invalid token") + } + if resp == nil || resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401, got %v", resp) + } +} + +func TestBrowserWS_SingleUse(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + + ts := "http" + strings.TrimPrefix(wsBase, "ws") + resp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + body := decodeBody(t, resp) + token, _ := body["token"].(string) + + // First use: token is consumed even though the agent lookup fails. + _, firstResp, err := dialWS(t, wsBase+"/ws/browser?token="+token+"&agent=unknown&service=ttyd", nil) + if err == nil { + t.Fatal("expected dial to fail for unknown agent") + } + if firstResp == nil || firstResp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404 for unknown agent, got %v", firstResp) + } + + // Second use of the same token must be rejected (single-use). + _, secondResp, err := dialWS(t, wsBase+"/ws/browser?token="+token+"&agent=unknown&service=ttyd", nil) + if err == nil { + t.Fatal("expected second use of the same token to fail") + } + if secondResp == nil || secondResp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401 for second use, got %v", secondResp) + } +} + +func TestBrowserWS_ExpiredTokenRejected(t *testing.T) { + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + _ = srv + + // Issue a token, then force expiry by advancing the store clock. + now := time.Now() + srv.wsTokens.now = func() time.Time { return now } + ts := "http" + strings.TrimPrefix(wsBase, "ws") + resp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + body := decodeBody(t, resp) + token, _ := body["token"].(string) + + srv.wsTokens.now = func() time.Time { return now.Add(61 * time.Second) } + + _, dialResp, err := dialWS(t, wsBase+"/ws/browser?token="+token+"&agent=agent-1&service=ttyd", nil) + if err == nil { + t.Fatal("expected expired token dial to fail") + } + if dialResp == nil || dialResp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401 for expired token, got %v", dialResp) + } +} + +func TestBrowserWS_UnknownAgentRejected(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + + ts := "http" + strings.TrimPrefix(wsBase, "ws") + resp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + body := decodeBody(t, resp) + token, _ := body["token"].(string) + + _, dialResp, err := dialWS(t, wsBase+"/ws/browser?token="+token+"&agent=unknown&service=ttyd", nil) + if err == nil { + t.Fatal("expected dial to fail for unknown agent") + } + if dialResp == nil || dialResp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404 for unknown agent, got %v", dialResp) + } +} + +func TestBrowserWS_UnknownServiceRejected(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + + ts := "http" + strings.TrimPrefix(wsBase, "ws") + resp := doRequest(t, http.MethodPost, ts+"/auth/runs?agent=agent-1", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + body := decodeBody(t, resp) + runToken, _ := body["run_token"].(string) + + agentConn := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer agentConn.Close() + + loginResp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + loginBody := decodeBody(t, loginResp) + token, _ := loginBody["token"].(string) + + _, dialResp, err := dialWS(t, wsBase+"/ws/browser?token="+token+"&agent=agent-1&service=doesnotexist", nil) + if err == nil { + t.Fatal("expected dial to fail for unknown service") + } + if dialResp == nil || dialResp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404 for unknown service, got %v", dialResp) + } +} + +func TestBrowserWS_ValidTokenConnects(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + + ts := "http" + strings.TrimPrefix(wsBase, "ws") + resp := doRequest(t, http.MethodPost, ts+"/auth/runs?agent=agent-1", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + body := decodeBody(t, resp) + runToken, _ := body["run_token"].(string) + + agentConn := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer agentConn.Close() + + loginResp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + loginBody := decodeBody(t, loginResp) + token, _ := loginBody["token"].(string) + + conn, dialResp, err := dialWS(t, wsBase+"/ws/browser?token="+token+"&agent=agent-1&service=ttyd", nil) + if err != nil { + t.Fatalf("expected valid browser dial to succeed: %v (resp %v)", err, dialResp) + } + defer conn.Close() + if dialResp.StatusCode != http.StatusSwitchingProtocols { + t.Errorf("expected 101, got %d", dialResp.StatusCode) + } +} + +func TestBrowserWS_ConcurrentSameTokenRaceSafe(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + // Register an agent so a successful dial can actually complete. + runResp := doRequest(t, http.MethodPost, ts+"/auth/runs?agent=agent-1", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + runBody := decodeBody(t, runResp) + runToken, _ := runBody["run_token"].(string) + agentConn := registerTestAgent(t, wsBase, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer agentConn.Close() + + resp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + body := decodeBody(t, resp) + token, _ := body["token"].(string) + + const n = 10 + var wg sync.WaitGroup + successes := make([]bool, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + conn, dialResp, err := dialWS(t, wsBase+"/ws/browser?token="+token+"&agent=agent-1&service=ttyd", nil) + if err == nil { + conn.Close() + successes[i] = true + return + } + _ = dialResp + }(i) + } + wg.Wait() + + successCount := 0 + for _, ok := range successes { + if ok { + successCount++ + } + } + if successCount != 1 { + t.Errorf("exactly one concurrent browser connect should succeed, got %d", successCount) + } +} + +func TestCredentialTypeSeparation(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + + ts := "http" + strings.TrimPrefix(wsBase, "ws") + + // Get a WS token. + loginResp := doRequest(t, http.MethodPost, ts+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + loginBody := decodeBody(t, loginResp) + wsToken, _ := loginBody["token"].(string) + + // Get a run token. + runResp := doRequest(t, http.MethodPost, ts+"/auth/runs?agent=agent-1", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + runBody := decodeBody(t, runResp) + runToken, _ := runBody["run_token"].(string) + + // WS token on /ws/agent must be rejected. + _, agentResp, err := dialWS(t, wsBase+"/ws/agent?agent=agent-1", map[string]string{ + "Authorization": "Bearer " + wsToken, + }) + if err == nil { + t.Fatal("WS token must be rejected on /ws/agent") + } + if agentResp == nil || agentResp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401 for WS token on /ws/agent, got %v", agentResp) + } + + // Run token on /ws/browser must be rejected. + _, browserResp, err := dialWS(t, wsBase+"/ws/browser?token="+runToken+"&agent=agent-1&service=ttyd", nil) + if err == nil { + t.Fatal("run token must be rejected on /ws/browser") + } + if browserResp == nil || browserResp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401 for run token on /ws/browser, got %v", browserResp) + } +} + +func TestRestartInvalidatesWSTokensButNotRunTokens(t *testing.T) { + // Simulate a restart: a fresh server with the same run-token key. + key := testRunKey() + + srv1, wsBase1 := newWSServer(t, "test-password", key) + ts1 := "http" + strings.TrimPrefix(wsBase1, "ws") + + loginResp := doRequest(t, http.MethodPost, ts1+"/auth/login", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + loginBody := decodeBody(t, loginResp) + wsToken, _ := loginBody["token"].(string) + + runResp := doRequest(t, http.MethodPost, ts1+"/auth/runs?agent=agent-1", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + runBody := decodeBody(t, runResp) + runToken, _ := runBody["run_token"].(string) + + // "Restart": stop the old server and start a new one with the same key. + srv1.Shutdown() + + srv2, wsBase2 := newWSServer(t, "test-password", key) + _ = srv2 + + // Pre-restart WS token is rejected. + _, wsResp, err := dialWS(t, wsBase2+"/ws/browser?token="+wsToken+"&agent=agent-1&service=ttyd", nil) + if err == nil { + t.Fatal("pre-restart WS token must be rejected after restart") + } + if wsResp == nil || wsResp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401 for pre-restart WS token, got %v", wsResp) + } + + // Pre-restart run token still registers an agent. + conn := registerTestAgent(t, wsBase2, runToken, "agent-1", []Service{{Name: "ttyd"}}) + defer conn.Close() + + ts2 := "http" + strings.TrimPrefix(wsBase2, "ws") + agentsResp := doRequest(t, http.MethodGet, ts2+"/agents", map[string]string{ + "Authorization": basicHeader("admin", "test-password"), + }) + defer agentsResp.Body.Close() + var agents []AgentInfo + if err := json.NewDecoder(agentsResp.Body).Decode(&agents); err != nil { + t.Fatalf("decode agents: %v", err) + } + found := false + for _, a := range agents { + if a.AgentID == "agent-1" { + found = true + } + } + if !found { + t.Error("pre-restart run token should still register the agent after restart") + } +} + +func TestRelayWS_NoTokenRejected(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + + _, resp, err := dialWS(t, wsBase+"/ws/relay", nil) + if err == nil { + t.Fatal("expected dial to fail without relay token") + } + if resp == nil || resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401, got %v", resp) + } +} + +func TestRelayWS_InvalidTokenRejected(t *testing.T) { + _, wsBase := newWSServer(t, "test-password", testRunKey()) + + _, resp, err := dialWS(t, wsBase+"/ws/relay?relayToken=garbage", nil) + if err == nil { + t.Fatal("expected dial to fail with invalid relay token") + } + if resp == nil || resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401, got %v", resp) + } +} + +func TestRelayWS_OneTimeToken(t *testing.T) { + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + + relayToken, err := srv.relayTokens.Issue() + if err != nil { + t.Fatalf("Issue relay token: %v", err) + } + + // First use succeeds. + conn, resp, err := dialWS(t, wsBase+"/ws/relay?relayToken="+relayToken, nil) + if err != nil { + t.Fatalf("first relay dial should succeed: %v (resp %v)", err, resp) + } + conn.Close() + + // Second use is rejected (one-time). + _, resp2, err := dialWS(t, wsBase+"/ws/relay?relayToken="+relayToken, nil) + if err == nil { + t.Fatal("second relay dial with the same token must fail") + } + if resp2 == nil || resp2.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401 for second relay use, got %v", resp2) + } +} + +func TestRelayWS_OneTimeEvenAfterFailedDial(t *testing.T) { + srv, wsBase := newWSServer(t, "test-password", testRunKey()) + + relayToken, err := srv.relayTokens.Issue() + if err != nil { + t.Fatalf("Issue relay token: %v", err) + } + + // First dial attempt: token is consumed server-side even if the connection fails. + // Use dialWS with extra headers that might cause issues — but the token gets consumed. + _, _, _ = dialWS(t, wsBase+"/ws/relay?relayToken="+relayToken, map[string]string{ + "Sec-WebSocket-Protocol": "invalid", + }) + + // A second dial with the same token must still be rejected. + _, resp2, err := dialWS(t, wsBase+"/ws/relay?relayToken="+relayToken, nil) + if err == nil { + t.Fatal("relay token must be single-use even after a failed first dial") + } + if resp2 == nil || resp2.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401, got %v", resp2) + } +} diff --git a/remote-bob/apiserver/internal/config/loader.go b/remote-bob/apiserver/internal/config/loader.go new file mode 100644 index 00000000..6956767e --- /dev/null +++ b/remote-bob/apiserver/internal/config/loader.go @@ -0,0 +1,53 @@ +package config + +import ( + "encoding/base64" + "fmt" + "os" +) + +// AppConfig holds all application configuration loaded from the environment. +// Secrets are injected as environment variables via --env-from-secret in both +// local mode and production (Code Engine). +type AppConfig struct { + GatewayPassword string + EncryptionKey []byte + LogLevel string +} + +// LoadConfig loads configuration from environment variables. In production +// (Code Engine) secrets are injected by --env-from-secret; in local mode they +// are set directly in the environment. +func LoadConfig() (*AppConfig, error) { + cfg := &AppConfig{} + + password := os.Getenv("GATEWAY_PASSWORD") + if password == "" { + return nil, fmt.Errorf("GATEWAY_PASSWORD is required") + } + cfg.GatewayPassword = password + + encKeyB64 := os.Getenv("ENCRYPTION_KEY") + if encKeyB64 == "" { + return nil, fmt.Errorf("ENCRYPTION_KEY is required") + } + encKey, err := base64.StdEncoding.DecodeString(encKeyB64) + if err != nil { + return nil, fmt.Errorf("failed to decode ENCRYPTION_KEY: %w", err) + } + if len(encKey) != 32 { + return nil, fmt.Errorf("ENCRYPTION_KEY must be exactly 32 bytes, got %d bytes", len(encKey)) + } + cfg.EncryptionKey = encKey + + cfg.LogLevel = getEnvOrDefault("LOG_LEVEL", "info") + + return cfg, nil +} + +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/remote-bob/apiserver/internal/config/loader_test.go b/remote-bob/apiserver/internal/config/loader_test.go new file mode 100644 index 00000000..e9e92e0c --- /dev/null +++ b/remote-bob/apiserver/internal/config/loader_test.go @@ -0,0 +1,129 @@ +package config + +import ( + "os" + "testing" +) + +const validEncKey = "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=" // base64 of 32 bytes + +func setEnv(t *testing.T, pairs ...string) { + t.Helper() + for i := 0; i < len(pairs); i += 2 { + t.Setenv(pairs[i], pairs[i+1]) + } +} + +func TestLoadConfig_Success(t *testing.T) { + setEnv(t, + "GATEWAY_PASSWORD", "test-password", + "ENCRYPTION_KEY", validEncKey, + "LOG_LEVEL", "debug", + ) + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() failed: %v", err) + } + + if cfg.GatewayPassword != "test-password" { + t.Errorf("GatewayPassword = %q, want %q", cfg.GatewayPassword, "test-password") + } + if len(cfg.EncryptionKey) != 32 { + t.Errorf("EncryptionKey length = %d, want 32", len(cfg.EncryptionKey)) + } + if cfg.LogLevel != "debug" { + t.Errorf("LogLevel = %q, want %q", cfg.LogLevel, "debug") + } +} + +func TestLoadConfig_DefaultLogLevel(t *testing.T) { + setEnv(t, + "GATEWAY_PASSWORD", "test-password", + "ENCRYPTION_KEY", validEncKey, + ) + os.Unsetenv("LOG_LEVEL") + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() failed: %v", err) + } + if cfg.LogLevel != "info" { + t.Errorf("LogLevel = %q, want %q", cfg.LogLevel, "info") + } +} + +func TestLoadConfig_MissingGatewayPassword(t *testing.T) { + setEnv(t, "ENCRYPTION_KEY", validEncKey) + os.Unsetenv("GATEWAY_PASSWORD") + + _, err := LoadConfig() + if err == nil { + t.Fatal("LoadConfig() succeeded, want error for missing GATEWAY_PASSWORD") + } + if !contains(err.Error(), "GATEWAY_PASSWORD") { + t.Errorf("Error = %q, want to contain 'GATEWAY_PASSWORD'", err.Error()) + } +} + +func TestLoadConfig_MissingEncryptionKey(t *testing.T) { + setEnv(t, "GATEWAY_PASSWORD", "test-password") + os.Unsetenv("ENCRYPTION_KEY") + + _, err := LoadConfig() + if err == nil { + t.Fatal("LoadConfig() succeeded, want error for missing ENCRYPTION_KEY") + } + if !contains(err.Error(), "ENCRYPTION_KEY") { + t.Errorf("Error = %q, want to contain 'ENCRYPTION_KEY'", err.Error()) + } +} + +func TestLoadConfig_ShortEncryptionKey(t *testing.T) { + setEnv(t, + "GATEWAY_PASSWORD", "test-password", + "ENCRYPTION_KEY", "MTIzNDU2Nzg5MDEyMzQ1Ng==", // base64 of 16 bytes + ) + + _, err := LoadConfig() + if err == nil { + t.Fatal("LoadConfig() succeeded, want error for short ENCRYPTION_KEY") + } + if !contains(err.Error(), "32 bytes") { + t.Errorf("Error = %q, want to contain '32 bytes'", err.Error()) + } +} + +func TestGetEnvOrDefault(t *testing.T) { + tests := []struct { + name string + key string + defaultValue string + envValue string + want string + }{ + {name: "env set", key: "TEST_VAR_SET", defaultValue: "default", envValue: "custom", want: "custom"}, + {name: "env not set", key: "TEST_VAR_UNSET", defaultValue: "default", envValue: "", want: "default"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue != "" { + t.Setenv(tt.key, tt.envValue) + } + got := getEnvOrDefault(tt.key, tt.defaultValue) + if got != tt.want { + t.Errorf("getEnvOrDefault(%q, %q) = %q, want %q", tt.key, tt.defaultValue, got, tt.want) + } + }) + } +} + +func contains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/remote-bob/apiserver/internal/log/log.go b/remote-bob/apiserver/internal/log/log.go new file mode 100644 index 00000000..13e5fd1e --- /dev/null +++ b/remote-bob/apiserver/internal/log/log.go @@ -0,0 +1,70 @@ +// Package log provides structured JSON logging using the standard library's slog. +package log + +import ( + "log/slog" + "os" + "time" +) + +var logger *slog.Logger + +func init() { + handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }) + logger = slog.New(handler) +} + +// Info logs an informational event with optional fields. +// Fields may be passed as a map[string]interface{} (single arg) or as +// alternating key/value pairs matching the slog variadic API. +func Info(event string, fields ...any) { + args := buildArgs(event, fields...) + logger.Info("", args...) +} + +// Warn logs a warning event with optional fields. +func Warn(event string, fields ...any) { + args := buildArgs(event, fields...) + logger.Warn("", args...) +} + +// Error logs an error event. err may be nil. +func Error(event string, err error, fields ...any) { + base := []any{"event", event, "ts", time.Now().UTC().Format(time.RFC3339)} + if err != nil { + base = append(base, "error", err.Error()) + } + base = append(base, flattenFields(fields...)...) + logger.Error("", base...) +} + +// Debug logs a debug event with optional fields. +func Debug(event string, fields ...any) { + args := buildArgs(event, fields...) + logger.Debug("", args...) +} + +// buildArgs prepends the standard event/ts keys and flattens any map or +// key/value fields passed by callers. +func buildArgs(event string, fields ...any) []any { + base := []any{"event", event, "ts", time.Now().UTC().Format(time.RFC3339)} + return append(base, flattenFields(fields...)...) +} + +// flattenFields accepts either a single map[string]interface{} argument +// (legacy call-site convention used in some handlers) or alternating +// key/value pairs (slog convention). +func flattenFields(fields ...any) []any { + if len(fields) == 1 { + if m, ok := fields[0].(map[string]interface{}); ok { + out := make([]any, 0, len(m)*2) + for k, v := range m { + out = append(out, k, v) + } + return out + } + } + return fields +} diff --git a/remote-bob/apiserver/internal/ws/conn.go b/remote-bob/apiserver/internal/ws/conn.go new file mode 100644 index 00000000..be2a5665 --- /dev/null +++ b/remote-bob/apiserver/internal/ws/conn.go @@ -0,0 +1,460 @@ +// Package ws implements a minimal WebSocket client and server using only the +// Go standard library (net/http, bufio, crypto/sha1, encoding/base64, etc.). +// +// RFC 6455 compliance notes: +// - Supports text (0x1) and binary (0x2) data frames. +// - Supports ping (0x9) / pong (0xA) control frames. +// - Close (0x8) frame is sent on graceful shutdown. +// - Client frames are masked; server frames are unmasked (per spec). +// - Fragmented frames are NOT supported — ttyd never sends them. +package ws + +import ( + "bufio" + "bytes" + "crypto/rand" + "crypto/sha1" //nolint:gosec — required by RFC 6455 + "encoding/base64" + "encoding/binary" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// Frame message types (RFC 6455 §11.8). +const ( + MsgText = 1 + MsgBinary = 2 + MsgClose = 8 + MsgPing = 9 + MsgPong = 10 +) + +// Frame is a single decoded WebSocket frame. +type Frame struct { + MessageType int + Payload []byte +} + +// Conn is a WebSocket connection backed by a raw net.Conn. +// +// Concurrency model: +// - ReadFrame must be called from a single goroutine only (bufio.Reader is +// not goroutine-safe). Control-frame responses (pong) are sent via writeMu. +// - WriteFrame / WriteFrameMasked are goroutine-safe; they hold writeMu. +// - Close is safe to call from any goroutine and is idempotent. +type Conn struct { + conn net.Conn + rw *bufio.ReadWriter + writeMu sync.Mutex // serialises all writes (WriteFrame, WriteFrameMasked, Close) + once sync.Once + doneCh chan struct{} + closeCode int // populated on a close frame from the peer +} + +// CloseCode returns the WebSocket close code from the peer's close frame, +// or 0 if no close frame has been received. +func (c *Conn) CloseCode() int { + return c.closeCode +} + +// Close sends a Close frame and closes the underlying connection. +func (c *Conn) Close() error { + // Best-effort close frame under the write mutex. + c.writeMu.Lock() + _ = c.writeFrameLocked(MsgClose, nil, false) + c.writeMu.Unlock() + + err := c.conn.Close() + c.once.Do(func() { close(c.doneCh) }) + return err +} + +// Done returns a channel that is closed when Close is called or the connection dies. +// Use this to park a goroutine without reading from the connection. +func (c *Conn) Done() <-chan struct{} { + return c.doneCh +} + +// ReadFrame reads the next complete data frame from the connection. +// Must be called from a single goroutine only. +// Control frames (ping/pong/close) are handled transparently: +// - Ping → send Pong and loop. +// - Close → return io.EOF. +// - Pong → discard and loop. +func (c *Conn) ReadFrame() (Frame, error) { + for { + msgType, payload, err := c.readRawFrame() + if err != nil { + c.once.Do(func() { close(c.doneCh) }) + return Frame{}, err + } + switch msgType { + case MsgPing: + // Pong must go through the write mutex so it does not interleave + // with concurrent WriteFrame calls from other goroutines. + c.writeMu.Lock() + _ = c.writeFrameLocked(MsgPong, payload, false) + c.writeMu.Unlock() + case MsgClose: + // Extract the 2-byte close code if present. + if len(payload) >= 2 { + c.closeCode = int(payload[0])<<8 | int(payload[1]) + } + c.writeMu.Lock() + _ = c.writeFrameLocked(MsgClose, nil, false) + c.writeMu.Unlock() + c.once.Do(func() { close(c.doneCh) }) + return Frame{}, io.EOF + case MsgPong: + // discard + default: + return Frame{MessageType: msgType, Payload: payload}, nil + } + } +} + +// WriteFrame sends an unmasked WebSocket frame (server → client). +// Safe to call concurrently from multiple goroutines. +func (c *Conn) WriteFrame(msgType int, payload []byte) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return c.writeFrameLocked(msgType, payload, false) +} + +// WriteFrameMasked sends a masked frame (client → server, required by RFC 6455). +// Safe to call concurrently from multiple goroutines. +func (c *Conn) WriteFrameMasked(msgType int, payload []byte) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return c.writeFrameLocked(msgType, payload, true) +} + +// IsCloseError reports whether err signals a WebSocket close with one of the +// given codes. err must be io.EOF (the value ReadFrame returns on close). +func IsCloseError(conn *Conn, err error, codes ...int) bool { + if err != io.EOF { + return false + } + code := conn.CloseCode() + for _, c := range codes { + if code == c { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// RFC 6455 framing +// --------------------------------------------------------------------------- + +func (c *Conn) readRawFrame() (msgType int, payload []byte, err error) { + // Byte 0: FIN bit + opcode. + b0, err := c.rw.ReadByte() + if err != nil { + return 0, nil, err + } + // FIN bit must be set for non-fragmented frames; we don't support fragmentation. + opcode := int(b0 & 0x0F) + + // Byte 1: MASK bit + payload length. + b1, err := c.rw.ReadByte() + if err != nil { + return 0, nil, err + } + masked := b1&0x80 != 0 + length := int64(b1 & 0x7F) + + switch length { + case 126: + var ext uint16 + if err = binary.Read(c.rw, binary.BigEndian, &ext); err != nil { + return 0, nil, err + } + length = int64(ext) + case 127: + var ext uint64 + if err = binary.Read(c.rw, binary.BigEndian, &ext); err != nil { + return 0, nil, err + } + length = int64(ext) + } + + var maskKey [4]byte + if masked { + if _, err = io.ReadFull(c.rw, maskKey[:]); err != nil { + return 0, nil, err + } + } + + payload = make([]byte, length) + if _, err = io.ReadFull(c.rw, payload); err != nil { + return 0, nil, err + } + if masked { + for i := range payload { + payload[i] ^= maskKey[i%4] + } + } + return opcode, payload, nil +} + +// writeFrameLocked writes a frame to the connection. +// Caller MUST hold writeMu. +func (c *Conn) writeFrameLocked(msgType int, payload []byte, masked bool) error { + length := len(payload) + + var header []byte + // Byte 0: FIN=1 + opcode. + header = append(header, byte(0x80|msgType)) + + maskBit := byte(0) + if masked { + maskBit = 0x80 + } + + switch { + case length <= 125: + header = append(header, maskBit|byte(length)) + case length <= 65535: + header = append(header, maskBit|126) + header = append(header, byte(length>>8), byte(length)) + default: + header = append(header, maskBit|127) + var ext [8]byte + binary.BigEndian.PutUint64(ext[:], uint64(length)) + header = append(header, ext[:]...) + } + + var maskKey [4]byte + if masked { + if _, err := rand.Read(maskKey[:]); err != nil { + return err + } + header = append(header, maskKey[:]...) + } + + frame := make([]byte, 0, len(header)+length) + frame = append(frame, header...) + + if masked { + maskedPayload := make([]byte, length) + for i, b := range payload { + maskedPayload[i] = b ^ maskKey[i%4] + } + frame = append(frame, maskedPayload...) + } else { + frame = append(frame, payload...) + } + + _, err := c.rw.Write(frame) + if err != nil { + return err + } + return c.rw.Flush() +} + +// --------------------------------------------------------------------------- +// Server upgrade (HTTP → WebSocket) +// --------------------------------------------------------------------------- + +const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +// Upgrade performs the server-side WebSocket handshake. +// It accepts any origin and echoes back the "tty" subprotocol when requested. +func Upgrade(w http.ResponseWriter, r *http.Request) (*Conn, error) { + if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + http.Error(w, "not a websocket upgrade", http.StatusBadRequest) + return nil, fmt.Errorf("not a websocket upgrade") + } + + key := r.Header.Get("Sec-Websocket-Key") + if key == "" { + http.Error(w, "missing Sec-Websocket-Key", http.StatusBadRequest) + return nil, fmt.Errorf("missing Sec-Websocket-Key") + } + + // Compute accept token (RFC 6455 §4.2.2 step 5.4). + h := sha1.New() //nolint:gosec + h.Write([]byte(key + wsGUID)) + accept := base64.StdEncoding.EncodeToString(h.Sum(nil)) + + hj, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "server does not support hijacking", http.StatusInternalServerError) + return nil, fmt.Errorf("hijacking not supported") + } + conn, brw, err := hj.Hijack() + if err != nil { + return nil, fmt.Errorf("hijack: %w", err) + } + + // Drain any bytes already buffered by the HTTP server before we switch to + // our own reader. This avoids the "invalid Body.Read call after hijacked" + // panic that occurs when the hijacked bufio.Reader still references the + // HTTP server's internal connReader. + var preamble []byte + if brw.Reader.Buffered() > 0 { + preamble = make([]byte, brw.Reader.Buffered()) + _, _ = io.ReadFull(brw.Reader, preamble) + } + + // Build a fresh bufio.ReadWriter directly on the net.Conn so we have no + // references left to the HTTP server's internal connReader. + br := bufio.NewReaderSize(conn, 4096) + bw := bufio.NewWriterSize(conn, 4096) + if len(preamble) > 0 { + br = bufio.NewReaderSize(io.MultiReader(bytes.NewReader(preamble), conn), 4096) + } + rw := bufio.NewReadWriter(br, bw) + + // Build and flush the 101 response by hand (we own the conn now). + resp := "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-Websocket-Accept: " + accept + "\r\n" + + // Echo the "tty" subprotocol if the client requested it. + if proto := r.Header.Get("Sec-Websocket-Protocol"); strings.Contains(proto, "tty") { + resp += "Sec-Websocket-Protocol: tty\r\n" + } + resp += "\r\n" + + if _, err := rw.WriteString(resp); err != nil { + conn.Close() + return nil, fmt.Errorf("write handshake: %w", err) + } + if err := rw.Flush(); err != nil { + conn.Close() + return nil, fmt.Errorf("flush handshake: %w", err) + } + + return &Conn{conn: conn, rw: rw, doneCh: make(chan struct{})}, nil +} + +// --------------------------------------------------------------------------- +// Client dial (WebSocket client handshake) +// --------------------------------------------------------------------------- + +// DialHeader is a set of additional HTTP headers to send with the WebSocket upgrade request. +type DialHeader map[string]string + +// DialResult is returned by DialFull and carries both the connection and the +// HTTP response for callers that need to inspect the status code on failure. +type DialResult struct { + Conn *Conn + Response *http.Response +} + +// Dial opens a WebSocket connection to rawURL. It uses net.Dial directly so +// we retain access to the raw net.Conn after the HTTP 101 upgrade — +// http.Transport does not expose the underlying connection after switching +// protocols. Additional HTTP headers (e.g. Authorization) can be passed via +// extraHeaders. +func Dial(rawURL string, extraHeaders ...DialHeader) (*Conn, error) { + res, err := dialFull(rawURL, extraHeaders...) + if err != nil { + return nil, err + } + return res.Conn, nil +} + +// DialFull is like Dial but also returns the HTTP response so callers can +// inspect the status code when the upgrade is rejected. +func DialFull(rawURL string, extraHeaders ...DialHeader) (DialResult, error) { + return dialFull(rawURL, extraHeaders...) +} + +func dialFull(rawURL string, extraHeaders ...DialHeader) (DialResult, error) { + u, err := url.Parse(rawURL) + if err != nil { + return DialResult{}, fmt.Errorf("dial: parse url %q: %w", rawURL, err) + } + + // Determine network address and whether TLS is needed. + host := u.Host + switch u.Scheme { + case "ws": + if !strings.Contains(host, ":") { + host += ":80" + } + case "wss": + if !strings.Contains(host, ":") { + host += ":443" + } + default: + return DialResult{}, fmt.Errorf("dial: unsupported scheme %q", u.Scheme) + } + + // Dial raw TCP (wss/TLS not needed for local Docker compose). + netConn, err := net.DialTimeout("tcp", host, 10*time.Second) + if err != nil { + return DialResult{}, fmt.Errorf("dial tcp %s: %w", host, err) + } + + // Generate random nonce. + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + netConn.Close() + return DialResult{}, err + } + key := base64.StdEncoding.EncodeToString(nonce) + + // Send the HTTP/1.1 Upgrade request. + requestPath := u.RequestURI() + if requestPath == "" { + requestPath = "/" + } + reqLine := fmt.Sprintf( + "GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"+ + "Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: tty\r\n", + requestPath, u.Host, key, + ) + for _, hdr := range extraHeaders { + for k, v := range hdr { + reqLine += k + ": " + v + "\r\n" + } + } + reqLine += "\r\n" + if _, err := netConn.Write([]byte(reqLine)); err != nil { + netConn.Close() + return DialResult{}, fmt.Errorf("dial write request: %w", err) + } + + // Read and validate the 101 response. + br := bufio.NewReaderSize(netConn, 4096) + resp, err := http.ReadResponse(br, nil) + if err != nil { + netConn.Close() + return DialResult{}, fmt.Errorf("dial read response: %w", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusSwitchingProtocols { + netConn.Close() + return DialResult{Response: resp}, fmt.Errorf("dial: unexpected status %d (want 101)", resp.StatusCode) + } + + // Verify the accept token. + h := sha1.New() //nolint:gosec + h.Write([]byte(key + wsGUID)) + expected := base64.StdEncoding.EncodeToString(h.Sum(nil)) + if got := resp.Header.Get("Sec-Websocket-Accept"); got != expected { + netConn.Close() + return DialResult{Response: resp}, fmt.Errorf("dial: invalid Sec-Websocket-Accept: got %q want %q", got, expected) + } + + bw := bufio.NewWriterSize(netConn, 4096) + conn := &Conn{ + conn: netConn, + rw: bufio.NewReadWriter(br, bw), + doneCh: make(chan struct{}), + } + return DialResult{Conn: conn, Response: resp}, nil +} diff --git a/remote-bob/browser-client/addon-fit.js b/remote-bob/browser-client/addon-fit.js new file mode 100644 index 00000000..a6b3cdc6 --- /dev/null +++ b/remote-bob/browser-client/addon-fit.js @@ -0,0 +1,2 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(function(){return(()=>{"use strict";var e={775:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0;var r=function(){function e(){}return e.prototype.activate=function(e){this._terminal=e},e.prototype.dispose=function(){},e.prototype.fit=function(){var e=this.proposeDimensions();if(e&&this._terminal){var t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}},e.prototype.proposeDimensions=function(){if(this._terminal&&this._terminal.element&&this._terminal.element.parentElement){var e=this._terminal._core;if(0!==e._renderService.dimensions.actualCellWidth&&0!==e._renderService.dimensions.actualCellHeight){var t=window.getComputedStyle(this._terminal.element.parentElement),r=parseInt(t.getPropertyValue("height")),i=Math.max(0,parseInt(t.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),o=r-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=i-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-e.viewport.scrollBarWidth;return{cols:Math.max(2,Math.floor(a/e._renderService.dimensions.actualCellWidth)),rows:Math.max(1,Math.floor(o/e._renderService.dimensions.actualCellHeight))}}}},e}();t.FitAddon=r}},t={};return function r(i){if(t[i])return t[i].exports;var n=t[i]={exports:{}};return e[i](n,n.exports,r),n.exports}(775)})()})); +//# sourceMappingURL=xterm-addon-fit.js.map \ No newline at end of file diff --git a/remote-bob/browser-client/fonts/SymbolsNerdFont-LICENSE b/remote-bob/browser-client/fonts/SymbolsNerdFont-LICENSE new file mode 100644 index 00000000..06eb073d --- /dev/null +++ b/remote-bob/browser-client/fonts/SymbolsNerdFont-LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/remote-bob/browser-client/fonts/SymbolsNerdFont.woff2 b/remote-bob/browser-client/fonts/SymbolsNerdFont.woff2 new file mode 100644 index 00000000..960b4aad Binary files /dev/null and b/remote-bob/browser-client/fonts/SymbolsNerdFont.woff2 differ diff --git a/remote-bob/browser-client/single-session.html b/remote-bob/browser-client/single-session.html new file mode 100644 index 00000000..7aa3d7e1 --- /dev/null +++ b/remote-bob/browser-client/single-session.html @@ -0,0 +1,306 @@ + + + + + + Remote Bob + + + + +
+ Remote Bob +
+ +
+ +
+ +
+ +
+ +
+
+
Connection lost
+
Reconnecting…
+ +
+ +
+
+
+
+ + + + + + diff --git a/remote-bob/browser-client/single-session.js b/remote-bob/browser-client/single-session.js new file mode 100644 index 00000000..ea4e8f98 --- /dev/null +++ b/remote-bob/browser-client/single-session.js @@ -0,0 +1,695 @@ +/** + * single-session.js — Standalone browser client for Remote Bob v4. + * + * Loaded by single-session.html from a file:// URL (no webserver). Reads + * apiHost and agent from the URL query (identifiers only), prompts for the + * gateway password, logs in via POST {apiHost}/auth/login (Basic auth, + * username admin), then connects to /ws/browser and speaks the ttyd binary + * protocol directly: + * + * client → server: 0x30 + UTF-8 input bytes + * 0x31 + JSON {"columns":N,"rows":N} resize + * server → client: 0x30 + terminal output bytes + * 0x31 + UTF-8 window title + * 0x32 + JSON preferences (ignored) + * + * The password is kept in memory only — never in localStorage, the URL, or + * console output. On WS close the page shows a disconnect overlay and + * reconnects automatically: after an established session it always re-logins + * for a fresh single-use WS token (no re-prompt); the existing token is + * reused only for a retry after a first connect that never consumed it. + * + * To end a session use: ./remote-bob --end-session --config=.env + */ + +(function () { + 'use strict'; + + // ── Query-string parsing (identifiers only, never credentials) ────── + + var params = new URLSearchParams(window.location.search); + var apiHost = params.get('apiHost'); + var agent = params.get('agent'); + + // ── DOM references ───────────────────────────────────────────────── + + var errorOverlay = document.getElementById('error-overlay'); + var errorIcon = document.getElementById('error-icon'); + var errorMessage = document.getElementById('error-message'); + var loginOverlay = document.getElementById('login-overlay'); + var loginForm = document.getElementById('login-form'); + var passwordInput = document.getElementById('password-input'); + var loginButton = document.getElementById('login-button'); + var loginError = document.getElementById('login-error'); + var loginAgent = document.getElementById('login-agent'); + var disconnectOverlay = document.getElementById('disconnect-overlay'); + var disconnectMessage = document.getElementById('disconnect-message'); + var disconnectSubmessage = document.getElementById('disconnect-submessage'); + var retryButton = document.getElementById('retry-button'); + var toolbarTitle = document.getElementById('toolbar-title'); + var bellFlash = document.getElementById('bell-flash'); + + // ── Overlay helpers ───────────────────────────────────────────────── + + function showError(msg) { + if (errorOverlay && errorMessage) { + errorMessage.textContent = msg; + errorOverlay.classList.add('visible'); + } + } + + function showLogin() { + if (loginOverlay) { + loginOverlay.classList.add('visible'); + passwordInput.value = ''; + passwordInput.focus(); + } + } + + function hideLogin() { + if (loginOverlay) { + loginOverlay.classList.remove('visible'); + } + } + + function showLoginError(msg) { + if (loginError) { + loginError.textContent = msg; + } + } + + function clearLoginError() { + if (loginError) { + loginError.textContent = ''; + } + } + + function showDisconnect(msg, sub, showRetry) { + if (disconnectOverlay) { + disconnectMessage.textContent = msg; + disconnectSubmessage.textContent = sub; + retryButton.style.display = showRetry ? 'block' : 'none'; + disconnectOverlay.classList.add('visible'); + } + } + + function hideDisconnect() { + if (disconnectOverlay) { + disconnectOverlay.classList.remove('visible'); + } + } + + // ── Validate required parameters ──────────────────────────────────── + + if (!apiHost) { + showError('Missing required parameter: apiHost. Open this page with ?apiHost=&agent=.'); + return; + } + if (!agent) { + showError('Missing required parameter: agent. Open this page with ?apiHost=&agent=.'); + return; + } + + // ── URL helpers ────────────────────────────────────────────────────── + + // httpBase returns the http(s) base URL for the apiHost. apiHost may be + // passed with or without a scheme; localhost/loopback defaults to http, + // anything else to https. + function httpBase(host) { + if (/^https?:\/\//i.test(host)) { + return host; + } + if (/^localhost|^127\.|^\[?::1\]?/i.test(host)) { + return 'http://' + host; + } + return 'https://' + host; + } + + // wsBase derives the ws(s) base URL from the apiHost scheme. + function wsBase(host) { + return httpBase(host).replace(/^http/, 'ws'); + } + + // ── Terminal setup ─────────────────────────────────────────────────── + + var term = new Terminal({ + cursorBlink: true, + fontSize: 14, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + scrollback: 5000, + bellStyle: 'none', + theme: { + background: '#000000', + foreground: '#ffffff', + cursor: '#ffffff', + cursorAccent: '#000000', + selection: 'rgba(255, 255, 255, 0.3)', + black: '#000000', + red: '#cd3131', + green: '#0dbc79', + yellow: '#e5e510', + blue: '#2472c8', + magenta: '#bc3fbc', + cyan: '#11a8cd', + white: '#e5e5e5', + brightBlack: '#666666', + brightRed: '#f14c4c', + brightGreen: '#23d18b', + brightYellow: '#f5f543', + brightBlue: '#3b8eea', + brightMagenta: '#d670d6', + brightCyan: '#29b8db', + brightWhite: '#e5e5e5' + } + }); + + var fitAddon = new FitAddon.FitAddon(); + term.loadAddon(fitAddon); + + var terminalEl = document.getElementById('terminal'); + term.open(terminalEl); + fitAddon.fit(); + + // cleanTitle strips internal tmux command strings from the window title + // (ttyd sets the title to the full tmux attach command which is noisy). + // Anything that looks like a tmux invocation is replaced with "Remote Bob". + function cleanTitle(raw) { + if (!raw) { return 'Remote Bob'; } + // Match "tmux ..." or lines containing tmux subcommands. + if (/^\s*tmux\b/i.test(raw)) { return 'Remote Bob'; } + return raw; + } + + // Terminal title changes (OSC 0/2) propagate to the document title + // and the toolbar label. + term.onTitleChange(function (title) { + if (title) { + var clean = cleanTitle(title); + document.title = clean; + if (toolbarTitle) { + toolbarTitle.textContent = clean; + } + } + }); + + // BEL → visual bell (flash overlay). The vendored xterm.js has no + // built-in visual bell, so we implement one via the onBell event. + var bellTimer = null; + term.onBell(function () { + bellFlash.classList.add('visible'); + if (bellTimer) { + clearTimeout(bellTimer); + } + bellTimer = setTimeout(function () { + bellFlash.classList.remove('visible'); + bellTimer = null; + }, 200); + }); + + // ── Connection state ───────────────────────────────────────────────── + + var password = null; // in-memory only; never persisted or logged + var wsToken = null; // current single-use WS token + var socket = null; // current WebSocket + var connecting = false; // a WS connect attempt is in progress + var loginInFlight = false; // a login request is in progress + var everConnected = false; // a WS has reached OPEN at least once + var tokenRetried = false; // the current token was reused for one retry + var reconnectAttempt = 0; // consecutive failed reconnect attempts + var reconnectTimer = null; // pending reconnect timeout + + var MAX_RECONNECT_ATTEMPTS = 10; + var BACKOFF_DELAYS_MS = [1000, 2000, 4000, 8000, 16000, 30000]; + + function backoffDelay(attempt) { + return BACKOFF_DELAYS_MS[Math.min(attempt, BACKOFF_DELAYS_MS.length - 1)]; + } + + // ── ttyd binary protocol frames ────────────────────────────────────── + + var OPCODE_INPUT = 0x30; + var OPCODE_RESIZE = 0x31; + var OPCODE_OUTPUT = 0x30; + var OPCODE_TITLE = 0x31; + var OPCODE_PREFS = 0x32; + + var textEncoder = new TextEncoder(); + var textDecoder = new TextDecoder(); + + function sendInput(data) { + if (!socket || socket.readyState !== WebSocket.OPEN) { + return; + } + var bytes = textEncoder.encode(data); + var frame = new Uint8Array(bytes.length + 1); + frame[0] = OPCODE_INPUT; + frame.set(bytes, 1); + socket.send(frame); + } + + function sendResize(cols, rows) { + if (!socket || socket.readyState !== WebSocket.OPEN) { + return; + } + if (!cols || !rows) { + return; + } + var payload = textEncoder.encode(JSON.stringify({ columns: cols, rows: rows })); + var frame = new Uint8Array(payload.length + 1); + frame[0] = OPCODE_RESIZE; + frame.set(payload, 1); + socket.send(frame); + } + + // ── Terminal I/O wiring ────────────────────────────────────────────── + + term.onData(function (data) { + sendInput(data); + }); + + term.onResize(function (size) { + sendResize(size.cols, size.rows); + }); + + window.addEventListener('resize', function () { + fitAddon.fit(); + }); + + // ── Login ─────────────────────────────────────────────────────────── + + function basicAuthHeader(username, pass) { + var bytes = textEncoder.encode(username + ':' + pass); + var binary = ''; + for (var i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + + // userError marks errors that already carry a user-readable message so + // the network-error fallback does not overwrite them. + function userError(msg) { + var e = new Error(msg); + e.isUserError = true; + return e; + } + + // shutdownError is a sentinel thrown when the server returns 503 + // "shutting_down". The caller should show the shutting-down overlay + // instead of retrying. + function shutdownError() { + var e = new Error('shutting_down'); + e.isShutdown = true; + return e; + } + + // doLogin performs POST {apiHost}/auth/login with Basic auth and + // resolves to the WS token. Rejects with a user-readable message. + function doLogin(pass) { + return fetch(httpBase(apiHost) + '/auth/login', { + method: 'POST', + headers: { + 'Authorization': 'Basic ' + basicAuthHeader('admin', pass) + } + }).then(function (resp) { + if (resp.status === 401) { + throw userError('Invalid password. Please try again.'); + } + if (resp.status === 503) { + // Server is shutting down — check the body for our sentinel. + return resp.json().then(function (json) { + if (json && json.error === 'shutting_down') { + throw shutdownError(); + } + throw userError('Server is unavailable (503). Please try again later.'); + }).catch(function (e) { + if (e && e.isShutdown) { throw e; } + throw userError('Server is unavailable (503). Please try again later.'); + }); + } + if (!resp.ok) { + throw userError('Login failed (HTTP ' + resp.status + ').'); + } + return resp.json(); + }).then(function (json) { + if (!json || typeof json.token !== 'string' || json.token === '') { + throw userError('Server returned an invalid login response.'); + } + return json.token; + }).catch(function (err) { + if (err && (err.isUserError || err.isShutdown)) { + throw err; + } + throw userError('Could not reach the server at ' + apiHost + '. Check that it is running and reachable.'); + }); + } + + loginAgent.textContent = agent; + // Show the agent name in the toolbar immediately (before login). + if (toolbarTitle) { + toolbarTitle.textContent = agent; + } + + loginForm.addEventListener('submit', function (e) { + e.preventDefault(); + if (loginInFlight) { + return; + } + var pw = passwordInput.value; + if (!pw) { + showLoginError('Please enter a password.'); + return; + } + clearLoginError(); + loginInFlight = true; + loginButton.disabled = true; + doLogin(pw).then(function (token) { + // Login succeeded — authenticate before probing so a wrong password + // is rejected first. Now check whether the agent still exists before + // attempting the WS connect (avoids the "Connection failed" loop when + // the session ended while the browser was away or on a fresh open). + return probeAgent().then(function (state) { + loginInFlight = false; + loginButton.disabled = false; + if (state === 'shutdown') { + hideLogin(); + showShuttingDown(); + return; + } + if (state === 'gone') { + hideLogin(); + showSessionEnded(); + return; + } + // Agent alive — proceed. + password = pw; + wsToken = token; + hideLogin(); + fitAddon.fit(); + term.focus(); + connectWS(); + }); + }).catch(function (err) { + loginInFlight = false; + loginButton.disabled = false; + showLoginError(err.message); + passwordInput.focus(); + passwordInput.select(); + }); + }); + + // ── WebSocket connect / reconnect ─────────────────────────────────── + + function connectWS() { + if (connecting || !wsToken) { + return; + } + connecting = true; + var url = wsBase(apiHost) + '/ws/browser?token=' + encodeURIComponent(wsToken) + + '&agent=' + encodeURIComponent(agent) + '&service=ttyd'; + var s = new WebSocket(url); + s.binaryType = 'arraybuffer'; + socket = s; + + s.addEventListener('open', function () { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + connecting = false; + everConnected = true; + reconnectAttempt = 0; + tokenRetried = false; + hideDisconnect(); + fitAddon.fit(); + // The first frame after open must be a resize frame so the + // remote terminal adopts the current size. + sendResize(term.cols, term.rows); + term.focus(); + }); + + s.addEventListener('message', function (event) { + if (typeof event.data === 'string') { + return; // ttyd speaks binary frames only; ignore text + } + var bytes = new Uint8Array(event.data); + if (bytes.length === 0) { + return; + } + var opcode = bytes[0]; + var payload = new Uint8Array(bytes.subarray(1)); + switch (opcode) { + case OPCODE_OUTPUT: + term.write(payload); + break; + case OPCODE_TITLE: { + var rawTitle = textDecoder.decode(payload); + var cleanedTitle = cleanTitle(rawTitle); + document.title = cleanedTitle; + if (toolbarTitle) { + toolbarTitle.textContent = cleanedTitle; + } + break; + } + case OPCODE_PREFS: + // ttyd preferences JSON — not needed; ignore. + break; + default: + break; + } + }); + + s.addEventListener('error', function () { + // The close event always follows; handle state there. + }); + + s.addEventListener('close', function (event) { + connecting = false; + socket = null; + // Close code 4000 means the agent exited deliberately (e.g. /exit, + // idle timeout, or End Session). Show the ended screen instead of + // trying to reconnect. + if (event.code === 4000) { + showSessionEnded(); + return; + } + // If the server is shutting down, show the shutting-down overlay + // instead of reconnecting (HTTP 503 on the next login would catch + // it too, but the WS close gives us earlier feedback). + if (event.code === 1001 || event.code === 1006) { + // Could be a normal close or network drop. Check if we already + // know the server is shutting down. + if (serverShuttingDown) { + showShuttingDown(); + return; + } + } + scheduleReconnect(); + }); + } + + // ── Shutting-down state ────────────────────────────────────────────── + // Set when the server returns 503 shutting_down. Blocks all reconnects. + + var serverShuttingDown = false; + + function showShuttingDown() { + if (serverShuttingDown && sessionEndedShown) { return; } + serverShuttingDown = true; + if (socket) { + socket.close(); + socket = null; + } + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (disconnectOverlay) { + disconnectOverlay.classList.remove('visible'); + } + if (toolbarTitle) { + toolbarTitle.textContent = 'Shutting down…'; + } + document.title = 'Remote Bob — Shutting Down'; + // Reuse the disconnect overlay to show the shutting-down message with + // no retry button. Poll until the server goes away, then show ended. + if (disconnectOverlay) { + disconnectMessage.textContent = 'Session is shutting down…'; + disconnectSubmessage.textContent = 'Please wait. This window will update when the session has ended.'; + retryButton.style.display = 'none'; + disconnectOverlay.classList.add('visible'); + } + pollUntilGone(); + } + + // pollUntilGone polls /healthz every 2 s. When the server stops + // responding (network error), the session has ended — show ended screen. + function pollUntilGone() { + var pollTimer = setInterval(function () { + fetch(httpBase(apiHost) + '/healthz').then(function (resp) { + if (resp.status === 503 || !resp.ok) { + // Still alive but unhealthy — keep waiting. + } + // Still responding 200 — keep waiting. + }).catch(function () { + // Network error: server is gone. + clearInterval(pollTimer); + showSessionEnded(); + }); + }, 2000); + } + + // probeAgent checks whether the agent is still registered on the server. + // Resolves to "alive", "shutdown", or "gone" (agent not found / server gone). + function probeAgent() { + return fetch(httpBase(apiHost) + '/agents/' + encodeURIComponent(agent)) + .then(function (resp) { + if (resp.status === 503) { + return resp.json().then(function (json) { + if (json && json.error === 'shutting_down') { return 'shutdown'; } + return 'shutdown'; + }).catch(function () { return 'shutdown'; }); + } + if (resp.status === 404) { return 'gone'; } + if (resp.ok) { return 'alive'; } + return 'gone'; + }) + .catch(function () { + // Network error — server is unreachable (scaling down or gone). + return 'gone'; + }); + } + + function scheduleReconnect() { + if (reconnectTimer) { + return; + } + if (reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) { + showDisconnect( + everConnected ? 'Connection lost' : 'Connection failed', + 'Could not reconnect after ' + MAX_RECONNECT_ATTEMPTS + ' attempts. Click Retry to try again.', + true + ); + return; + } + var delay = backoffDelay(reconnectAttempt); + reconnectAttempt++; + showDisconnect( + everConnected ? 'Connection lost' : 'Connection failed', + 'Reconnecting… (attempt ' + reconnectAttempt + ')' + ); + reconnectTimer = setTimeout(function () { + reconnectTimer = null; + doReconnect(); + }, delay); + } + + function doReconnect() { + if (everConnected) { + // The single-use token was consumed by the successful connect; + // always re-login for a fresh token (password in memory). + reloginAndConnect(); + } else if (!tokenRetried) { + // The first connect never succeeded, so the token may not have + // been consumed — reuse it for one retry. + tokenRetried = true; + connectWS(); + } else { + reloginAndConnect(); + } + } + + function reloginAndConnect() { + if (loginInFlight) { + return; // a re-login is already in flight; do not duplicate + } + if (!password) { + // Password is gone (should not happen) — re-prompt the user. + showLogin(); + return; + } + loginInFlight = true; + // Before re-logging in, probe whether the agent still exists. + // This lets us show the correct terminal state immediately instead of + // hammering the server with login+WS attempts that will always fail + // when the session has ended (idle timeout, CE job finished, etc.). + probeAgent().then(function (state) { + if (state === 'shutdown') { + loginInFlight = false; + showShuttingDown(); + return; + } + if (state === 'gone') { + loginInFlight = false; + showSessionEnded(); + return; + } + // state === 'alive' — proceed with login + return doLogin(password).then(function (token) { + loginInFlight = false; + wsToken = token; + connectWS(); + }).catch(function (err) { + loginInFlight = false; + if (err && err.isShutdown) { + showShuttingDown(); + return; + } + scheduleReconnect(); + }); + }); + } + + retryButton.addEventListener('click', function () { + reconnectAttempt = 0; + tokenRetried = false; + doReconnect(); + }); + + // ── Session ended ──────────────────────────────────────────────────── + + var sessionEndedShown = false; + function showSessionEnded() { + if (sessionEndedShown) { return; } + sessionEndedShown = true; + if (socket) { + socket.close(); + socket = null; + } + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + // Update the toolbar to reflect ended state. + if (toolbarTitle) { + toolbarTitle.textContent = 'Session ended'; + } + document.title = 'Remote Bob — Session Ended'; + // Hide the disconnect overlay if showing; show a permanent ended message. + if (disconnectOverlay) { + disconnectOverlay.classList.remove('visible'); + } + // Show the error overlay reused as a "session ended" confirmation screen. + if (errorOverlay && errorMessage) { + errorOverlay.classList.add('ended'); + if (errorIcon) { errorIcon.textContent = '\u2713'; } // ✓ + errorMessage.textContent = 'The session has been ended. You can close this window.'; + errorOverlay.classList.add('visible'); + } + // Attempt window close — works when the window was opened by script. + setTimeout(function () { window.close(); }, 600); + } + + // ── Initial state: show the password prompt before any WS ─────────── + + showLogin(); + + // Close the socket cleanly when the page unloads, but do NOT call + // DELETE /agents — closing the window does not end the session. + window.addEventListener('beforeunload', function () { + if (socket) { + socket.close(); + } + }); + +})(); diff --git a/remote-bob/browser-client/xterm.css b/remote-bob/browser-client/xterm.css new file mode 100644 index 00000000..95fc61ed --- /dev/null +++ b/remote-bob/browser-client/xterm.css @@ -0,0 +1,190 @@ +/** + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * https://github.com/chjj/term.js + * @license MIT + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * Originally forked from (with the author's permission): + * Fabrice Bellard's javascript vt100 for jslinux: + * http://bellard.org/jslinux/ + * Copyright (c) 2011 Fabrice Bellard + * The original design remains. The terminal itself + * has been extended to include xterm CSI codes, among + * other features. + */ + +/** + * Default styles for xterm.js + */ + +.xterm { + cursor: text; + position: relative; + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; +} + +.xterm.focus, +.xterm:focus { + outline: none; +} + +.xterm .xterm-helpers { + position: absolute; + top: 0; + /** + * The z-index of the helpers must be higher than the canvases in order for + * IMEs to appear on top. + */ + z-index: 5; +} + +.xterm .xterm-helper-textarea { + padding: 0; + border: 0; + margin: 0; + /* Move textarea out of the screen to the far left, so that the cursor is not visible */ + position: absolute; + opacity: 0; + left: -9999em; + top: 0; + width: 0; + height: 0; + z-index: -5; + /** Prevent wrapping so the IME appears against the textarea at the correct position */ + white-space: nowrap; + overflow: hidden; + resize: none; +} + +.xterm .composition-view { + /* TODO: Composition position got messed up somewhere */ + background: #000; + color: #FFF; + display: none; + position: absolute; + white-space: nowrap; + z-index: 1; +} + +.xterm .composition-view.active { + display: block; +} + +.xterm .xterm-viewport { + /* On OS X this is required in order for the scroll bar to appear fully opaque */ + background-color: #000; + overflow-y: scroll; + cursor: default; + position: absolute; + right: 0; + left: 0; + top: 0; + bottom: 0; +} + +.xterm .xterm-screen { + position: relative; +} + +.xterm .xterm-screen canvas { + position: absolute; + left: 0; + top: 0; +} + +.xterm .xterm-scroll-area { + visibility: hidden; +} + +.xterm-char-measure-element { + display: inline-block; + visibility: hidden; + position: absolute; + top: 0; + left: -9999em; + line-height: normal; +} + +.xterm.enable-mouse-events { + /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ + cursor: default; +} + +.xterm.xterm-cursor-pointer, +.xterm .xterm-cursor-pointer { + cursor: pointer; +} + +.xterm.column-select.focus { + /* Column selection mode */ + cursor: crosshair; +} + +.xterm .xterm-accessibility, +.xterm .xterm-message { + position: absolute; + left: 0; + top: 0; + bottom: 0; + right: 0; + z-index: 10; + color: transparent; +} + +.xterm .live-region { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} + +.xterm-dim { + opacity: 0.5; +} + +.xterm-underline { + text-decoration: underline; +} + +.xterm-strikethrough { + text-decoration: line-through; +} + +.xterm-screen .xterm-decoration-container .xterm-decoration { + z-index: 6; + position: absolute; +} + +.xterm-decoration-overview-ruler { + z-index: 7; + position: absolute; + top: 0; + right: 0; + pointer-events: none; +} + +.xterm-decoration-top { + z-index: 2; + position: relative; +} diff --git a/remote-bob/browser-client/xterm.js b/remote-bob/browser-client/xterm.js new file mode 100644 index 00000000..9be4399a --- /dev/null +++ b/remote-bob/browser-client/xterm.js @@ -0,0 +1,2 @@ +!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var r=t();for(var i in r)("object"==typeof exports?exports:e)[i]=r[i]}}(self,(function(){return(()=>{"use strict";var e={4567:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)});Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;var o=r(9042),s=r(6114),a=r(9924),c=r(3656),l=r(844),h=r(5596),u=r(9631),f=function(e){function t(t,r){var i=e.call(this)||this;i._terminal=t,i._renderService=r,i._liveRegionLineCount=0,i._charsToConsume=[],i._charsToAnnounce="",i._accessibilityTreeRoot=document.createElement("div"),i._accessibilityTreeRoot.classList.add("xterm-accessibility"),i._accessibilityTreeRoot.tabIndex=0,i._rowContainer=document.createElement("div"),i._rowContainer.setAttribute("role","list"),i._rowContainer.classList.add("xterm-accessibility-tree"),i._rowElements=[];for(var n=0;ne;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()},t.prototype._createAccessibilityTreeNode=function(){var e=document.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e},t.prototype._onTab=function(e){for(var t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=o.tooMuchOutput)),s.isMac&&this._liveRegion.textContent&&this._liveRegion.textContent.length>0&&!this._liveRegion.parentNode&&setTimeout((function(){t._accessibilityTreeRoot.appendChild(t._liveRegion)}),0))},t.prototype._clearLiveRegion=function(){this._liveRegion.textContent="",this._liveRegionLineCount=0,s.isMac&&(0,u.removeElementFromParent)(this._liveRegion)},t.prototype._onKey=function(e){this._clearLiveRegion(),this._charsToConsume.push(e)},t.prototype._refreshRows=function(e,t){this._renderRowsDebouncer.refresh(e,t,this._terminal.rows)},t.prototype._renderRows=function(e,t){for(var r=this._terminal.buffer,i=r.lines.length.toString(),n=e;n<=t;n++){var o=r.translateBufferLineToString(r.ydisp+n,!0),s=(r.ydisp+n+1).toString(),a=this._rowElements[n];a&&(0===o.length?a.innerText=" ":a.textContent=o,a.setAttribute("aria-posinset",s),a.setAttribute("aria-setsize",i))}this._announceCharacters()},t.prototype._refreshRowsDimensions=function(){if(this._renderService.dimensions.actualCellHeight){this._rowElements.length!==this._terminal.rows&&this._onResize(this._terminal.rows);for(var e=0;e{function r(e){return e.replace(/\r?\n/g,"\r")}function i(e,t){return t?"[200~"+e+"[201~":e}function n(e,t,n){e=i(e=r(e),n.decPrivateModes.bracketedPasteMode),n.triggerDataEvent(e,!0),t.value=""}function o(e,t,r){var i=r.getBoundingClientRect(),n=e.clientX-i.left-10,o=e.clientY-i.top-10;t.style.width="20px",t.style.height="20px",t.style.left=n+"px",t.style.top=o+"px",t.style.zIndex="1000",t.focus()}Object.defineProperty(t,"__esModule",{value:!0}),t.rightClickHandler=t.moveTextAreaUnderMouseCursor=t.paste=t.handlePasteEvent=t.copyHandler=t.bracketTextForPaste=t.prepareTextForTerminal=void 0,t.prepareTextForTerminal=r,t.bracketTextForPaste=i,t.copyHandler=function(e,t){e.clipboardData&&e.clipboardData.setData("text/plain",t.selectionText),e.preventDefault()},t.handlePasteEvent=function(e,t,r){e.stopPropagation(),e.clipboardData&&n(e.clipboardData.getData("text/plain"),t,r)},t.paste=n,t.moveTextAreaUnderMouseCursor=o,t.rightClickHandler=function(e,t,r,i,n){o(e,t,r),n&&i.rightClickSelect(e),t.value=i.selectionText,t.select()}},7239:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorContrastCache=void 0;var r=function(){function e(){this._color={},this._rgba={}}return e.prototype.clear=function(){this._color={},this._rgba={}},e.prototype.setCss=function(e,t,r){this._rgba[e]||(this._rgba[e]={}),this._rgba[e][t]=r},e.prototype.getCss=function(e,t){return this._rgba[e]?this._rgba[e][t]:void 0},e.prototype.setColor=function(e,t,r){this._color[e]||(this._color[e]={}),this._color[e][t]=r},e.prototype.getColor=function(e,t){return this._color[e]?this._color[e][t]:void 0},e}();t.ColorContrastCache=r},5680:function(e,t,r){var i=this&&this.__read||function(e,t){var r="function"==typeof Symbol&&e[Symbol.iterator];if(!r)return e;var i,n,o=r.call(e),s=[];try{for(;(void 0===t||t-- >0)&&!(i=o.next()).done;)s.push(i.value)}catch(e){n={error:e}}finally{try{i&&!i.done&&(r=o.return)&&r.call(o)}finally{if(n)throw n.error}}return s};Object.defineProperty(t,"__esModule",{value:!0}),t.ColorManager=t.DEFAULT_ANSI_COLORS=void 0;var n=r(8055),o=r(7239),s=n.css.toColor("#ffffff"),a=n.css.toColor("#000000"),c=n.css.toColor("#ffffff"),l=n.css.toColor("#000000"),h={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117};t.DEFAULT_ANSI_COLORS=Object.freeze(function(){for(var e=[n.css.toColor("#2e3436"),n.css.toColor("#cc0000"),n.css.toColor("#4e9a06"),n.css.toColor("#c4a000"),n.css.toColor("#3465a4"),n.css.toColor("#75507b"),n.css.toColor("#06989a"),n.css.toColor("#d3d7cf"),n.css.toColor("#555753"),n.css.toColor("#ef2929"),n.css.toColor("#8ae234"),n.css.toColor("#fce94f"),n.css.toColor("#729fcf"),n.css.toColor("#ad7fa8"),n.css.toColor("#34e2e2"),n.css.toColor("#eeeeec")],t=[0,95,135,175,215,255],r=0;r<216;r++){var i=t[r/36%6|0],o=t[r/6%6|0],s=t[r%6];e.push({css:n.channels.toCss(i,o,s),rgba:n.channels.toRgba(i,o,s)})}for(r=0;r<24;r++){var a=8+10*r;e.push({css:n.channels.toCss(a,a,a),rgba:n.channels.toRgba(a,a,a)})}return e}());var u=function(){function e(e,r){this.allowTransparency=r;var i=e.createElement("canvas");i.width=1,i.height=1;var u=i.getContext("2d");if(!u)throw new Error("Could not get rendering context");this._ctx=u,this._ctx.globalCompositeOperation="copy",this._litmusColor=this._ctx.createLinearGradient(0,0,1,1),this._contrastCache=new o.ColorContrastCache,this.colors={foreground:s,background:a,cursor:c,cursorAccent:l,selectionTransparent:h,selectionOpaque:n.color.blend(a,h),selectionForeground:void 0,ansi:t.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache},this._updateRestoreColors()}return e.prototype.onOptionsChange=function(e){"minimumContrastRatio"===e&&this._contrastCache.clear()},e.prototype.setTheme=function(e){void 0===e&&(e={}),this.colors.foreground=this._parseColor(e.foreground,s),this.colors.background=this._parseColor(e.background,a),this.colors.cursor=this._parseColor(e.cursor,c,!0),this.colors.cursorAccent=this._parseColor(e.cursorAccent,l,!0),this.colors.selectionTransparent=this._parseColor(e.selection,h,!0),this.colors.selectionOpaque=n.color.blend(this.colors.background,this.colors.selectionTransparent);var r={css:"",rgba:0};this.colors.selectionForeground=e.selectionForeground?this._parseColor(e.selectionForeground,r):void 0,this.colors.selectionForeground===r&&(this.colors.selectionForeground=void 0),n.color.isOpaque(this.colors.selectionTransparent)&&(this.colors.selectionTransparent=n.color.opacity(this.colors.selectionTransparent,.3)),this.colors.ansi[0]=this._parseColor(e.black,t.DEFAULT_ANSI_COLORS[0]),this.colors.ansi[1]=this._parseColor(e.red,t.DEFAULT_ANSI_COLORS[1]),this.colors.ansi[2]=this._parseColor(e.green,t.DEFAULT_ANSI_COLORS[2]),this.colors.ansi[3]=this._parseColor(e.yellow,t.DEFAULT_ANSI_COLORS[3]),this.colors.ansi[4]=this._parseColor(e.blue,t.DEFAULT_ANSI_COLORS[4]),this.colors.ansi[5]=this._parseColor(e.magenta,t.DEFAULT_ANSI_COLORS[5]),this.colors.ansi[6]=this._parseColor(e.cyan,t.DEFAULT_ANSI_COLORS[6]),this.colors.ansi[7]=this._parseColor(e.white,t.DEFAULT_ANSI_COLORS[7]),this.colors.ansi[8]=this._parseColor(e.brightBlack,t.DEFAULT_ANSI_COLORS[8]),this.colors.ansi[9]=this._parseColor(e.brightRed,t.DEFAULT_ANSI_COLORS[9]),this.colors.ansi[10]=this._parseColor(e.brightGreen,t.DEFAULT_ANSI_COLORS[10]),this.colors.ansi[11]=this._parseColor(e.brightYellow,t.DEFAULT_ANSI_COLORS[11]),this.colors.ansi[12]=this._parseColor(e.brightBlue,t.DEFAULT_ANSI_COLORS[12]),this.colors.ansi[13]=this._parseColor(e.brightMagenta,t.DEFAULT_ANSI_COLORS[13]),this.colors.ansi[14]=this._parseColor(e.brightCyan,t.DEFAULT_ANSI_COLORS[14]),this.colors.ansi[15]=this._parseColor(e.brightWhite,t.DEFAULT_ANSI_COLORS[15]),this._contrastCache.clear(),this._updateRestoreColors()},e.prototype.restoreColor=function(e){if(void 0!==e)switch(e){case 256:this.colors.foreground=this._restoreColors.foreground;break;case 257:this.colors.background=this._restoreColors.background;break;case 258:this.colors.cursor=this._restoreColors.cursor;break;default:this.colors.ansi[e]=this._restoreColors.ansi[e]}else for(var t=0;t=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(t,"__esModule",{value:!0}),t.removeElementFromParent=void 0,t.removeElementFromParent=function(){for(var e,t,i,n=[],o=0;o{Object.defineProperty(t,"__esModule",{value:!0}),t.addDisposableDomListener=void 0,t.addDisposableDomListener=function(e,t,r,i){e.addEventListener(t,r,i);var n=!1;return{dispose:function(){n||(n=!0,e.removeEventListener(t,r,i))}}}},3551:function(e,t,r){var i=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},n=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseZone=t.Linkifier=void 0;var o=r(8460),s=r(2585),a=function(){function e(e,t,r){this._bufferService=e,this._logService=t,this._unicodeService=r,this._linkMatchers=[],this._nextLinkMatcherId=0,this._onShowLinkUnderline=new o.EventEmitter,this._onHideLinkUnderline=new o.EventEmitter,this._onLinkTooltip=new o.EventEmitter,this._rowsToLinkify={start:void 0,end:void 0}}return Object.defineProperty(e.prototype,"onShowLinkUnderline",{get:function(){return this._onShowLinkUnderline.event},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onHideLinkUnderline",{get:function(){return this._onHideLinkUnderline.event},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onLinkTooltip",{get:function(){return this._onLinkTooltip.event},enumerable:!1,configurable:!0}),e.prototype.attachToDom=function(e,t){this._element=e,this._mouseZoneManager=t},e.prototype.linkifyRows=function(t,r){var i=this;this._mouseZoneManager&&(void 0===this._rowsToLinkify.start||void 0===this._rowsToLinkify.end?(this._rowsToLinkify.start=t,this._rowsToLinkify.end=r):(this._rowsToLinkify.start=Math.min(this._rowsToLinkify.start,t),this._rowsToLinkify.end=Math.max(this._rowsToLinkify.end,r)),this._mouseZoneManager.clearAll(t,r),this._rowsTimeoutId&&clearTimeout(this._rowsTimeoutId),this._rowsTimeoutId=setTimeout((function(){return i._linkifyRows()}),e._timeBeforeLatency))},e.prototype._linkifyRows=function(){this._rowsTimeoutId=void 0;var e=this._bufferService.buffer;if(void 0!==this._rowsToLinkify.start&&void 0!==this._rowsToLinkify.end){var t=e.ydisp+this._rowsToLinkify.start;if(!(t>=e.lines.length)){for(var r=e.ydisp+Math.min(this._rowsToLinkify.end,this._bufferService.rows)+1,i=Math.ceil(2e3/this._bufferService.cols),n=this._bufferService.buffer.iterator(!1,t,r,i,i);n.hasNext();)for(var o=n.next(),s=0;s=0;t--)if(e.priority<=this._linkMatchers[t].priority)return void this._linkMatchers.splice(t+1,0,e);this._linkMatchers.splice(0,0,e)}else this._linkMatchers.push(e)},e.prototype.deregisterLinkMatcher=function(e){for(var t=0;t>9&511:void 0;r.validationCallback?r.validationCallback(a,(function(e){n._rowsTimeoutId||e&&n._addLink(l[1],l[0]-n._bufferService.buffer.ydisp,a,r,f)})):c._addLink(l[1],l[0]-c._bufferService.buffer.ydisp,a,r,f)},c=this;null!==(i=o.exec(t))&&"break"!==a(););},e.prototype._addLink=function(e,t,r,i,n){var o=this;if(this._mouseZoneManager&&this._element){var s=this._unicodeService.getStringCellWidth(r),a=e%this._bufferService.cols,l=t+Math.floor(e/this._bufferService.cols),h=(a+s)%this._bufferService.cols,u=l+Math.floor((a+s)/this._bufferService.cols);0===h&&(h=this._bufferService.cols,u--),this._mouseZoneManager.add(new c(a+1,l+1,h+1,u+1,(function(e){if(i.handler)return i.handler(e,r);var t=window.open();t?(t.opener=null,t.location.href=r):console.warn("Opening link blocked as opener could not be cleared")}),(function(){o._onShowLinkUnderline.fire(o._createLinkHoverEvent(a,l,h,u,n)),o._element.classList.add("xterm-cursor-pointer")}),(function(e){o._onLinkTooltip.fire(o._createLinkHoverEvent(a,l,h,u,n)),i.hoverTooltipCallback&&i.hoverTooltipCallback(e,r,{start:{x:a,y:l},end:{x:h,y:u}})}),(function(){o._onHideLinkUnderline.fire(o._createLinkHoverEvent(a,l,h,u,n)),o._element.classList.remove("xterm-cursor-pointer"),i.hoverLeaveCallback&&i.hoverLeaveCallback()}),(function(e){return!i.willLinkActivate||i.willLinkActivate(e,r)})))}},e.prototype._createLinkHoverEvent=function(e,t,r,i,n){return{x1:e,y1:t,x2:r,y2:i,cols:this._bufferService.cols,fg:n}},e._timeBeforeLatency=200,e=i([n(0,s.IBufferService),n(1,s.ILogService),n(2,s.IUnicodeService)],e)}();t.Linkifier=a;var c=function(e,t,r,i,n,o,s,a,c){this.x1=e,this.y1=t,this.x2=r,this.y2=i,this.clickCallback=n,this.hoverCallback=o,this.tooltipCallback=s,this.leaveCallback=a,this.willLinkActivate=c};t.MouseZone=c},6465:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)}),o=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},s=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}},a=this&&this.__values||function(e){var t="function"==typeof Symbol&&Symbol.iterator,r=t&&e[t],i=0;if(r)return r.call(e);if(e&&"number"==typeof e.length)return{next:function(){return e&&i>=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")},c=this&&this.__read||function(e,t){var r="function"==typeof Symbol&&e[Symbol.iterator];if(!r)return e;var i,n,o=r.call(e),s=[];try{for(;(void 0===t||t-- >0)&&!(i=o.next()).done;)s.push(i.value)}catch(e){n={error:e}}finally{try{i&&!i.done&&(r=o.return)&&r.call(o)}finally{if(n)throw n.error}}return s};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier2=void 0;var l=r(2585),h=r(8460),u=r(844),f=r(3656),_=function(e){function t(t){var r=e.call(this)||this;return r._bufferService=t,r._linkProviders=[],r._linkCacheDisposables=[],r._isMouseOut=!0,r._activeLine=-1,r._onShowLinkUnderline=r.register(new h.EventEmitter),r._onHideLinkUnderline=r.register(new h.EventEmitter),r.register((0,u.getDisposeArrayDisposable)(r._linkCacheDisposables)),r}return n(t,e),Object.defineProperty(t.prototype,"currentLink",{get:function(){return this._currentLink},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onShowLinkUnderline",{get:function(){return this._onShowLinkUnderline.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onHideLinkUnderline",{get:function(){return this._onHideLinkUnderline.event},enumerable:!1,configurable:!0}),t.prototype.registerLinkProvider=function(e){var t=this;return this._linkProviders.push(e),{dispose:function(){var r=t._linkProviders.indexOf(e);-1!==r&&t._linkProviders.splice(r,1)}}},t.prototype.attachToDom=function(e,t,r){var i=this;this._element=e,this._mouseService=t,this._renderService=r,this.register((0,f.addDisposableDomListener)(this._element,"mouseleave",(function(){i._isMouseOut=!0,i._clearCurrentLink()}))),this.register((0,f.addDisposableDomListener)(this._element,"mousemove",this._onMouseMove.bind(this))),this.register((0,f.addDisposableDomListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this.register((0,f.addDisposableDomListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))},t.prototype._onMouseMove=function(e){if(this._lastMouseEvent=e,this._element&&this._mouseService){var t=this._positionFromMouseEvent(e,this._element,this._mouseService);if(t){this._isMouseOut=!1;for(var r=e.composedPath(),i=0;ie?this._bufferService.cols:s.link.range.end.x,l=a;l<=c;l++){if(r.has(l)){n.splice(o--,1);break}r.add(l)}}},t.prototype._checkLinkProviderResult=function(e,t,r){var i,n=this;if(!this._activeProviderReplies)return r;for(var o=this._activeProviderReplies.get(e),s=!1,a=0;a=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,u.disposeArray)(this._linkCacheDisposables))},t.prototype._handleNewLink=function(e){var t=this;if(this._element&&this._lastMouseEvent&&this._mouseService){var r=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);r&&this._linkAtPosition(e.link,r)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:function(){var e,r;return null===(r=null===(e=t._currentLink)||void 0===e?void 0:e.state)||void 0===r?void 0:r.decorations.pointerCursor},set:function(e){var r,i;(null===(r=t._currentLink)||void 0===r?void 0:r.state)&&t._currentLink.state.decorations.pointerCursor!==e&&(t._currentLink.state.decorations.pointerCursor=e,t._currentLink.state.isHovered&&(null===(i=t._element)||void 0===i||i.classList.toggle("xterm-cursor-pointer",e)))}},underline:{get:function(){var e,r;return null===(r=null===(e=t._currentLink)||void 0===e?void 0:e.state)||void 0===r?void 0:r.decorations.underline},set:function(r){var i,n,o;(null===(i=t._currentLink)||void 0===i?void 0:i.state)&&(null===(o=null===(n=t._currentLink)||void 0===n?void 0:n.state)||void 0===o?void 0:o.decorations.underline)!==r&&(t._currentLink.state.decorations.underline=r,t._currentLink.state.isHovered&&t._fireUnderlineEvent(e.link,r))}}}),this._renderService&&this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange((function(e){var r=0===e.start?0:e.start+1+t._bufferService.buffer.ydisp;t._clearCurrentLink(r,e.end+1+t._bufferService.buffer.ydisp)}))))}},t.prototype._linkHover=function(e,t,r){var i;(null===(i=this._currentLink)||void 0===i?void 0:i.state)&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(r,t.text)},t.prototype._fireUnderlineEvent=function(e,t){var r=e.range,i=this._bufferService.buffer.ydisp,n=this._createLinkUnderlineEvent(r.start.x-1,r.start.y-i-1,r.end.x,r.end.y-i-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(n)},t.prototype._linkLeave=function(e,t,r){var i;(null===(i=this._currentLink)||void 0===i?void 0:i.state)&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(r,t.text)},t.prototype._linkAtPosition=function(e,t){var r=e.range.start.y===e.range.end.y,i=e.range.start.yt.y;return(r&&e.range.start.x<=t.x&&e.range.end.x>=t.x||i&&e.range.end.x>=t.x||n&&e.range.start.x<=t.x||i&&n)&&e.range.start.y<=t.y&&e.range.end.y>=t.y},t.prototype._positionFromMouseEvent=function(e,t,r){var i=r.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(i)return{x:i[0],y:i[1]+this._bufferService.buffer.ydisp}},t.prototype._createLinkUnderlineEvent=function(e,t,r,i,n){return{x1:e,y1:t,x2:r,y2:i,cols:this._bufferService.cols,fg:n}},o([s(0,l.IBufferService)],t)}(u.Disposable);t.Linkifier2=_},9042:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0,t.promptLabel="Terminal input",t.tooMuchOutput="Too much output to announce, navigate to rows manually to read"},6954:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)}),o=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},s=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseZoneManager=void 0;var a=r(844),c=r(3656),l=r(4725),h=r(2585),u=function(e){function t(t,r,i,n,o,s){var a=e.call(this)||this;return a._element=t,a._screenElement=r,a._bufferService=i,a._mouseService=n,a._selectionService=o,a._optionsService=s,a._zones=[],a._areZonesActive=!1,a._lastHoverCoords=[void 0,void 0],a._initialSelectionLength=0,a.register((0,c.addDisposableDomListener)(a._element,"mousedown",(function(e){return a._onMouseDown(e)}))),a._mouseMoveListener=function(e){return a._onMouseMove(e)},a._mouseLeaveListener=function(e){return a._onMouseLeave(e)},a._clickListener=function(e){return a._onClick(e)},a}return n(t,e),t.prototype.dispose=function(){e.prototype.dispose.call(this),this._deactivate()},t.prototype.add=function(e){this._zones.push(e),1===this._zones.length&&this._activate()},t.prototype.clearAll=function(e,t){if(0!==this._zones.length){e&&t||(e=0,t=this._bufferService.rows-1);for(var r=0;re&&i.y1<=t+1||i.y2>e&&i.y2<=t+1||i.y1t+1)&&(this._currentZone&&this._currentZone===i&&(this._currentZone.leaveCallback(),this._currentZone=void 0),this._zones.splice(r--,1))}0===this._zones.length&&this._deactivate()}},t.prototype._activate=function(){this._areZonesActive||(this._areZonesActive=!0,this._element.addEventListener("mousemove",this._mouseMoveListener),this._element.addEventListener("mouseleave",this._mouseLeaveListener),this._element.addEventListener("click",this._clickListener))},t.prototype._deactivate=function(){this._areZonesActive&&(this._areZonesActive=!1,this._element.removeEventListener("mousemove",this._mouseMoveListener),this._element.removeEventListener("mouseleave",this._mouseLeaveListener),this._element.removeEventListener("click",this._clickListener))},t.prototype._onMouseMove=function(e){this._lastHoverCoords[0]===e.pageX&&this._lastHoverCoords[1]===e.pageY||(this._onHover(e),this._lastHoverCoords=[e.pageX,e.pageY])},t.prototype._onHover=function(e){var t=this,r=this._findZoneEventAt(e);r!==this._currentZone&&(this._currentZone&&(this._currentZone.leaveCallback(),this._currentZone=void 0,this._tooltipTimeout&&clearTimeout(this._tooltipTimeout)),r&&(this._currentZone=r,r.hoverCallback&&r.hoverCallback(e),this._tooltipTimeout=window.setTimeout((function(){return t._onTooltip(e)}),this._optionsService.rawOptions.linkTooltipHoverDuration)))},t.prototype._onTooltip=function(e){this._tooltipTimeout=void 0;var t=this._findZoneEventAt(e);null==t||t.tooltipCallback(e)},t.prototype._onMouseDown=function(e){if(this._initialSelectionLength=this._getSelectionLength(),this._areZonesActive){var t=this._findZoneEventAt(e);(null==t?void 0:t.willLinkActivate(e))&&(e.preventDefault(),e.stopImmediatePropagation())}},t.prototype._onMouseLeave=function(e){this._currentZone&&(this._currentZone.leaveCallback(),this._currentZone=void 0,this._tooltipTimeout&&clearTimeout(this._tooltipTimeout))},t.prototype._onClick=function(e){var t=this._findZoneEventAt(e),r=this._getSelectionLength();t&&r===this._initialSelectionLength&&(t.clickCallback(e),e.preventDefault(),e.stopImmediatePropagation())},t.prototype._getSelectionLength=function(){var e=this._selectionService.selectionText;return e?e.length:0},t.prototype._findZoneEventAt=function(e){var t=this._mouseService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows);if(t)for(var r=t[0],i=t[1],n=0;n=o.x1&&r=o.x1||i===o.y2&&ro.y1&&i=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderDebouncer=void 0;var i=function(){function e(e){this._renderCallback=e,this._refreshCallbacks=[]}return e.prototype.dispose=function(){this._animationFrame&&(window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)},e.prototype.addRefreshCallback=function(e){var t=this;return this._refreshCallbacks.push(e),this._animationFrame||(this._animationFrame=window.requestAnimationFrame((function(){return t._innerRefresh()}))),this._animationFrame},e.prototype.refresh=function(e,t,r){var i=this;this._rowCount=r,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,this._animationFrame||(this._animationFrame=window.requestAnimationFrame((function(){return i._innerRefresh()})))},e.prototype._innerRefresh=function(){if(this._animationFrame=void 0,void 0!==this._rowStart&&void 0!==this._rowEnd&&void 0!==this._rowCount){var e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}else this._runRefreshCallbacks()},e.prototype._runRefreshCallbacks=function(){var e,t;try{for(var i=r(this._refreshCallbacks),n=i.next();!n.done;n=i.next())(0,n.value)(0)}catch(t){e={error:t}}finally{try{n&&!n.done&&(t=i.return)&&t.call(i)}finally{if(e)throw e.error}}this._refreshCallbacks=[]},e}();t.RenderDebouncer=i},5596:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)});Object.defineProperty(t,"__esModule",{value:!0}),t.ScreenDprMonitor=void 0;var o=function(e){function t(){var t=null!==e&&e.apply(this,arguments)||this;return t._currentDevicePixelRatio=window.devicePixelRatio,t}return n(t,e),t.prototype.setListener=function(e){var t=this;this._listener&&this.clearListener(),this._listener=e,this._outerListener=function(){t._listener&&(t._listener(window.devicePixelRatio,t._currentDevicePixelRatio),t._updateDpr())},this._updateDpr()},t.prototype.dispose=function(){e.prototype.dispose.call(this),this.clearListener()},t.prototype._updateDpr=function(){var e;this._outerListener&&(null===(e=this._resolutionMediaMatchList)||void 0===e||e.removeListener(this._outerListener),this._currentDevicePixelRatio=window.devicePixelRatio,this._resolutionMediaMatchList=window.matchMedia("screen and (resolution: "+window.devicePixelRatio+"dppx)"),this._resolutionMediaMatchList.addListener(this._outerListener))},t.prototype.clearListener=function(){this._resolutionMediaMatchList&&this._listener&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._listener=void 0,this._outerListener=void 0)},t}(r(844).Disposable);t.ScreenDprMonitor=o},3236:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)}),o=this&&this.__values||function(e){var t="function"==typeof Symbol&&Symbol.iterator,r=t&&e[t],i=0;if(r)return r.call(e);if(e&&"number"==typeof e.length)return{next:function(){return e&&i>=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")},s=this&&this.__read||function(e,t){var r="function"==typeof Symbol&&e[Symbol.iterator];if(!r)return e;var i,n,o=r.call(e),s=[];try{for(;(void 0===t||t-- >0)&&!(i=o.next()).done;)s.push(i.value)}catch(e){n={error:e}}finally{try{i&&!i.done&&(r=o.return)&&r.call(o)}finally{if(n)throw n.error}}return s},a=this&&this.__spreadArray||function(e,t,r){if(r||2===arguments.length)for(var i,n=0,o=t.length;n4)&&t.coreMouseService.triggerMouseEvent({col:n.x-33,row:n.y-33,button:r,action:i,ctrl:e.ctrlKey,alt:e.altKey,shift:e.shiftKey})}var n={mouseup:null,wheel:null,mousedrag:null,mousemove:null},o=function(t){return i(t),t.buttons||(e._document.removeEventListener("mouseup",n.mouseup),n.mousedrag&&e._document.removeEventListener("mousemove",n.mousedrag)),e.cancel(t)},s=function(t){return i(t),e.cancel(t,!0)},a=function(e){e.buttons&&i(e)},c=function(e){e.buttons||i(e)};this.register(this.coreMouseService.onProtocolChange((function(t){t?("debug"===e.optionsService.rawOptions.logLevel&&e._logService.debug("Binding to mouse events:",e.coreMouseService.explainEvents(t)),e.element.classList.add("enable-mouse-events"),e._selectionService.disable()):(e._logService.debug("Unbinding from mouse events."),e.element.classList.remove("enable-mouse-events"),e._selectionService.enable()),8&t?n.mousemove||(r.addEventListener("mousemove",c),n.mousemove=c):(r.removeEventListener("mousemove",n.mousemove),n.mousemove=null),16&t?n.wheel||(r.addEventListener("wheel",s,{passive:!1}),n.wheel=s):(r.removeEventListener("wheel",n.wheel),n.wheel=null),2&t?n.mouseup||(n.mouseup=o):(e._document.removeEventListener("mouseup",n.mouseup),n.mouseup=null),4&t?n.mousedrag||(n.mousedrag=a):(e._document.removeEventListener("mousemove",n.mousedrag),n.mousedrag=null)}))),this.coreMouseService.activeProtocol=this.coreMouseService.activeProtocol,this.register((0,y.addDisposableDomListener)(r,"mousedown",(function(t){if(t.preventDefault(),e.focus(),e.coreMouseService.areMouseEventsActive&&!e._selectionService.shouldForceSelection(t))return i(t),n.mouseup&&e._document.addEventListener("mouseup",n.mouseup),n.mousedrag&&e._document.addEventListener("mousemove",n.mousedrag),e.cancel(t)}))),this.register((0,y.addDisposableDomListener)(r,"wheel",(function(t){if(!n.wheel){if(!e.buffer.hasScrollback){var r=e.viewport.getLinesScrolled(t);if(0===r)return;for(var i=u.C0.ESC+(e.coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(t.deltaY<0?"A":"B"),o="",s=0;s=65&&e.key.charCodeAt(0)<=90)||(this._unprocessedDeadKey?(this._unprocessedDeadKey=!1,!0):(r.key!==u.C0.ETX&&r.key!==u.C0.CR||(this.textarea.value=""),this._onKey.fire({key:r.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(r.key,!0),this.optionsService.rawOptions.screenReaderMode?void(this._keyDownHandled=!0):this.cancel(e,!0))))},t.prototype._isThirdLevelShift=function(e,t){var r=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?r:r&&(!t.keyCode||t.keyCode>47)},t.prototype._keyUp=function(e){this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e)||(function(e){return 16===e.keyCode||17===e.keyCode||18===e.keyCode}(e)||this.focus(),this.updateCursorStyle(e),this._keyPressHandled=!1)},t.prototype._keyPress=function(e){var t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(this.cancel(e),e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))},t.prototype._inputEvent=function(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;var t=e.data;return this.coreService.triggerDataEvent(t,!0),this.cancel(e),!0}return!1},t.prototype.bell=function(){var e;this._soundBell()&&(null===(e=this._soundService)||void 0===e||e.playBellSound()),this._onBell.fire()},t.prototype.resize=function(t,r){t!==this.cols||r!==this.rows?e.prototype.resize.call(this,t,r):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()},t.prototype._afterResize=function(e,t){var r,i;null===(r=this._charSizeService)||void 0===r||r.measure(),null===(i=this.viewport)||void 0===i||i.syncScrollArea(!0)},t.prototype.clear=function(){if(0!==this.buffer.ybase||0!==this.buffer.y){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(var e=1;e{Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0;var r=function(){function e(e,t){void 0===t&&(t=1e3),this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}return e.prototype.dispose=function(){this._refreshTimeoutID&&clearTimeout(this._refreshTimeoutID)},e.prototype.refresh=function(e,t,r){var i=this;this._rowCount=r,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;var n=Date.now();if(n-this._lastRefreshMs>=this._debounceThresholdMS)this._lastRefreshMs=n,this._innerRefresh();else if(!this._additionalRefreshRequested){var o=n-this._lastRefreshMs,s=this._debounceThresholdMS-o;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout((function(){i._lastRefreshMs=Date.now(),i._innerRefresh(),i._additionalRefreshRequested=!1,i._refreshTimeoutID=void 0}),s)}},e.prototype._innerRefresh=function(){if(void 0!==this._rowStart&&void 0!==this._rowEnd&&void 0!==this._rowCount){var e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}},e}();t.TimeBasedDebouncer=r},1680:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)}),o=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},s=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;var a=r(844),c=r(3656),l=r(4725),h=r(2585),u=function(e){function t(t,r,i,n,o,s,a,l){var h=e.call(this)||this;return h._scrollLines=t,h._viewportElement=r,h._scrollArea=i,h._element=n,h._bufferService=o,h._optionsService=s,h._charSizeService=a,h._renderService=l,h.scrollBarWidth=0,h._currentRowHeight=0,h._currentScaledCellHeight=0,h._lastRecordedBufferLength=0,h._lastRecordedViewportHeight=0,h._lastRecordedBufferHeight=0,h._lastTouchY=0,h._lastScrollTop=0,h._wheelPartialScroll=0,h._refreshAnimationFrame=null,h._ignoreNextScrollEvent=!1,h.scrollBarWidth=h._viewportElement.offsetWidth-h._scrollArea.offsetWidth||15,h.register((0,c.addDisposableDomListener)(h._viewportElement,"scroll",h._onScroll.bind(h))),h._activeBuffer=h._bufferService.buffer,h.register(h._bufferService.buffers.onBufferActivate((function(e){return h._activeBuffer=e.activeBuffer}))),h._renderDimensions=h._renderService.dimensions,h.register(h._renderService.onDimensionsChange((function(e){return h._renderDimensions=e}))),setTimeout((function(){return h.syncScrollArea()}),0),h}return n(t,e),t.prototype.onThemeChange=function(e){this._viewportElement.style.backgroundColor=e.background.css},t.prototype._refresh=function(e){var t=this;if(e)return this._innerRefresh(),void(null!==this._refreshAnimationFrame&&cancelAnimationFrame(this._refreshAnimationFrame));null===this._refreshAnimationFrame&&(this._refreshAnimationFrame=requestAnimationFrame((function(){return t._innerRefresh()})))},t.prototype._innerRefresh=function(){if(this._charSizeService.height>0){this._currentRowHeight=this._renderService.dimensions.scaledCellHeight/window.devicePixelRatio,this._currentScaledCellHeight=this._renderService.dimensions.scaledCellHeight,this._lastRecordedViewportHeight=this._viewportElement.offsetHeight;var e=Math.round(this._currentRowHeight*this._lastRecordedBufferLength)+(this._lastRecordedViewportHeight-this._renderService.dimensions.canvasHeight);this._lastRecordedBufferHeight!==e&&(this._lastRecordedBufferHeight=e,this._scrollArea.style.height=this._lastRecordedBufferHeight+"px")}var t=this._bufferService.buffer.ydisp*this._currentRowHeight;this._viewportElement.scrollTop!==t&&(this._ignoreNextScrollEvent=!0,this._viewportElement.scrollTop=t),this._refreshAnimationFrame=null},t.prototype.syncScrollArea=function(e){if(void 0===e&&(e=!1),this._lastRecordedBufferLength!==this._bufferService.buffer.lines.length)return this._lastRecordedBufferLength=this._bufferService.buffer.lines.length,void this._refresh(e);this._lastRecordedViewportHeight===this._renderService.dimensions.canvasHeight&&this._lastScrollTop===this._activeBuffer.ydisp*this._currentRowHeight&&this._renderDimensions.scaledCellHeight===this._currentScaledCellHeight||this._refresh(e)},t.prototype._onScroll=function(e){if(this._lastScrollTop=this._viewportElement.scrollTop,this._viewportElement.offsetParent){if(this._ignoreNextScrollEvent)return this._ignoreNextScrollEvent=!1,void this._scrollLines(0);var t=Math.round(this._lastScrollTop/this._currentRowHeight)-this._bufferService.buffer.ydisp;this._scrollLines(t)}},t.prototype._bubbleScroll=function(e,t){var r=this._viewportElement.scrollTop+this._lastRecordedViewportHeight;return!(t<0&&0!==this._viewportElement.scrollTop||t>0&&r0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(t*=this._bufferService.rows),t},t.prototype._applyScrollModifier=function(e,t){var r=this._optionsService.rawOptions.fastScrollModifier;return"alt"===r&&t.altKey||"ctrl"===r&&t.ctrlKey||"shift"===r&&t.shiftKey?e*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:e*this._optionsService.rawOptions.scrollSensitivity},t.prototype.onTouchStart=function(e){this._lastTouchY=e.touches[0].pageY},t.prototype.onTouchMove=function(e){var t=this._lastTouchY-e.touches[0].pageY;return this._lastTouchY=e.touches[0].pageY,0!==t&&(this._viewportElement.scrollTop+=t,this._bubbleScroll(e,t))},o([s(4,h.IBufferService),s(5,h.IOptionsService),s(6,l.ICharSizeService),s(7,l.IRenderService)],t)}(a.Disposable);t.Viewport=u},3107:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)}),o=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},s=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}},a=this&&this.__values||function(e){var t="function"==typeof Symbol&&Symbol.iterator,r=t&&e[t],i=0;if(r)return r.call(e);if(e&&"number"==typeof e.length)return{next:function(){return e&&i>=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;var c=r(3656),l=r(4725),h=r(844),u=r(2585),f=function(e){function t(t,r,i,n){var o=e.call(this)||this;return o._screenElement=t,o._bufferService=r,o._decorationService=i,o._renderService=n,o._decorationElements=new Map,o._altBufferIsActive=!1,o._dimensionsChanged=!1,o._container=document.createElement("div"),o._container.classList.add("xterm-decoration-container"),o._screenElement.appendChild(o._container),o.register(o._renderService.onRenderedViewportChange((function(){return o._queueRefresh()}))),o.register(o._renderService.onDimensionsChange((function(){o._dimensionsChanged=!0,o._queueRefresh()}))),o.register((0,c.addDisposableDomListener)(window,"resize",(function(){return o._queueRefresh()}))),o.register(o._bufferService.buffers.onBufferActivate((function(){o._altBufferIsActive=o._bufferService.buffer===o._bufferService.buffers.alt}))),o.register(o._decorationService.onDecorationRegistered((function(){return o._queueRefresh()}))),o.register(o._decorationService.onDecorationRemoved((function(e){return o._removeDecoration(e)}))),o}return n(t,e),t.prototype.dispose=function(){this._container.remove(),this._decorationElements.clear(),e.prototype.dispose.call(this)},t.prototype._queueRefresh=function(){var e=this;void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback((function(){e.refreshDecorations(),e._animationFrame=void 0})))},t.prototype.refreshDecorations=function(){var e,t;try{for(var r=a(this._decorationService.decorations),i=r.next();!i.done;i=r.next()){var n=i.value;this._renderDecoration(n)}}catch(t){e={error:t}}finally{try{i&&!i.done&&(t=r.return)&&t.call(r)}finally{if(e)throw e.error}}this._dimensionsChanged=!1},t.prototype._renderDecoration=function(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)},t.prototype._createElement=function(e){var t,r=document.createElement("div");r.classList.add("xterm-decoration"),r.style.width=Math.round((e.options.width||1)*this._renderService.dimensions.actualCellWidth)+"px",r.style.height=(e.options.height||1)*this._renderService.dimensions.actualCellHeight+"px",r.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.actualCellHeight+"px",r.style.lineHeight=this._renderService.dimensions.actualCellHeight+"px";var i=null!==(t=e.options.x)&&void 0!==t?t:0;return i&&i>this._bufferService.cols&&(r.style.display="none"),this._refreshXPosition(e,r),r},t.prototype._refreshStyle=function(e){var t=this,r=e.marker.line-this._bufferService.buffers.active.ydisp;if(r<0||r>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{var i=this._decorationElements.get(e);i||(e.onDispose((function(){return t._removeDecoration(e)})),i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i)),i.style.top=r*this._renderService.dimensions.actualCellHeight+"px",i.style.display=this._altBufferIsActive?"none":"block",e.onRenderEmitter.fire(i)}},t.prototype._refreshXPosition=function(e,t){var r;if(void 0===t&&(t=e.element),t){var i=null!==(r=e.options.x)&&void 0!==r?r:0;"right"===(e.options.anchor||"left")?t.style.right=i?i*this._renderService.dimensions.actualCellWidth+"px":"":t.style.left=i?i*this._renderService.dimensions.actualCellWidth+"px":""}},t.prototype._removeDecoration=function(e){var t;null===(t=this._decorationElements.get(e))||void 0===t||t.remove(),this._decorationElements.delete(e)},o([s(1,u.IBufferService),s(2,u.IDecorationService),s(3,l.IRenderService)],t)}(h.Disposable);t.BufferDecorationRenderer=f},5871:function(e,t){var r=this&&this.__values||function(e){var t="function"==typeof Symbol&&Symbol.iterator,r=t&&e[t],i=0;if(r)return r.call(e);if(e&&"number"==typeof e.length)return{next:function(){return e&&i>=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0;var i=function(){function e(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}return Object.defineProperty(e.prototype,"zones",{get:function(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones},enumerable:!1,configurable:!0}),e.prototype.clear=function(){this._zones.length=0,this._zonePoolIndex=0},e.prototype.addDecoration=function(e){var t,i;if(e.options.overviewRulerOptions){try{for(var n=r(this._zones),o=n.next();!o.done;o=n.next()){var s=o.value;if(s.color===e.options.overviewRulerOptions.color&&s.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(s,e.marker.line))return;if(this._lineAdjacentToZone(s,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(s,e.marker.line)}}}catch(e){t={error:e}}finally{try{o&&!o.done&&(i=n.return)&&i.call(n)}finally{if(t)throw t.error}}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine},e.prototype._lineAdjacentToZone=function(e,t,r){return t>=e.startBufferLine-this._linePadding[r||"full"]&&t<=e.endBufferLine+this._linePadding[r||"full"]},e.prototype._addLineToZone=function(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)},e}();t.ColorZoneStore=i},5744:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)}),o=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},s=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}},a=this&&this.__values||function(e){var t="function"==typeof Symbol&&Symbol.iterator,r=t&&e[t],i=0;if(r)return r.call(e);if(e&&"number"==typeof e.length)return{next:function(){return e&&i>=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;var c=r(5871),l=r(3656),h=r(4725),u=r(844),f=r(2585),_={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0},p={full:0,left:0,center:0,right:0},v=function(e){function t(t,r,i,n,o,s){var a,l=e.call(this)||this;l._viewportElement=t,l._screenElement=r,l._bufferService=i,l._decorationService=n,l._renderService=o,l._optionsService=s,l._colorZoneStore=new c.ColorZoneStore,l._shouldUpdateDimensions=!0,l._shouldUpdateAnchor=!0,l._lastKnownBufferLength=0,l._canvas=document.createElement("canvas"),l._canvas.classList.add("xterm-decoration-overview-ruler"),l._refreshCanvasDimensions(),null===(a=l._viewportElement.parentElement)||void 0===a||a.insertBefore(l._canvas,l._viewportElement);var h=l._canvas.getContext("2d");if(!h)throw new Error("Ctx cannot be null");return l._ctx=h,l._registerDecorationListeners(),l._registerBufferChangeListeners(),l._registerDimensionChangeListeners(),l}return n(t,e),Object.defineProperty(t.prototype,"_width",{get:function(){return this._optionsService.options.overviewRulerWidth||0},enumerable:!1,configurable:!0}),t.prototype._registerDecorationListeners=function(){var e=this;this.register(this._decorationService.onDecorationRegistered((function(){return e._queueRefresh(void 0,!0)}))),this.register(this._decorationService.onDecorationRemoved((function(){return e._queueRefresh(void 0,!0)})))},t.prototype._registerBufferChangeListeners=function(){var e=this;this.register(this._renderService.onRenderedViewportChange((function(){return e._queueRefresh()}))),this.register(this._bufferService.buffers.onBufferActivate((function(){e._canvas.style.display=e._bufferService.buffer===e._bufferService.buffers.alt?"none":"block"}))),this.register(this._bufferService.onScroll((function(){e._lastKnownBufferLength!==e._bufferService.buffers.normal.lines.length&&(e._refreshDrawHeightConstants(),e._refreshColorZonePadding())})))},t.prototype._registerDimensionChangeListeners=function(){var e=this;this.register(this._renderService.onRender((function(){e._containerHeight&&e._containerHeight===e._screenElement.clientHeight||(e._queueRefresh(!0),e._containerHeight=e._screenElement.clientHeight)}))),this.register(this._optionsService.onOptionChange((function(t){"overviewRulerWidth"===t&&e._queueRefresh(!0)}))),this.register((0,l.addDisposableDomListener)(window,"resize",(function(){e._queueRefresh(!0)}))),this._queueRefresh(!0)},t.prototype.dispose=function(){var t;null===(t=this._canvas)||void 0===t||t.remove(),e.prototype.dispose.call(this)},t.prototype._refreshDrawConstants=function(){var e=Math.floor(this._canvas.width/3),t=Math.ceil(this._canvas.width/3);d.full=this._canvas.width,d.left=e,d.center=t,d.right=e,this._refreshDrawHeightConstants(),p.full=0,p.left=0,p.center=d.left,p.right=d.left+d.center},t.prototype._refreshDrawHeightConstants=function(){_.full=Math.round(2*window.devicePixelRatio);var e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*window.devicePixelRatio);_.left=t,_.center=t,_.right=t},t.prototype._refreshColorZonePadding=function(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*_.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*_.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*_.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*_.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length},t.prototype._refreshCanvasDimensions=function(){this._canvas.style.width=this._width+"px",this._canvas.width=Math.round(this._width*window.devicePixelRatio),this._canvas.style.height=this._screenElement.clientHeight+"px",this._canvas.height=Math.round(this._screenElement.clientHeight*window.devicePixelRatio),this._refreshDrawConstants(),this._refreshColorZonePadding()},t.prototype._refreshDecorations=function(){var e,t,r,i,n,o;this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();try{for(var s=a(this._decorationService.decorations),c=s.next();!c.done;c=s.next()){var l=c.value;this._colorZoneStore.addDecoration(l)}}catch(t){e={error:t}}finally{try{c&&!c.done&&(t=s.return)&&t.call(s)}finally{if(e)throw e.error}}this._ctx.lineWidth=1;var h=this._colorZoneStore.zones;try{for(var u=a(h),f=u.next();!f.done;f=u.next())"full"!==(p=f.value).position&&this._renderColorZone(p)}catch(e){r={error:e}}finally{try{f&&!f.done&&(i=u.return)&&i.call(u)}finally{if(r)throw r.error}}try{for(var _=a(h),d=_.next();!d.done;d=_.next()){var p;"full"===(p=d.value).position&&this._renderColorZone(p)}}catch(e){n={error:e}}finally{try{d&&!d.done&&(o=_.return)&&o.call(_)}finally{if(n)throw n.error}}this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1},t.prototype._renderColorZone=function(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(p[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-_[e.position||"full"]/2),d[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+_[e.position||"full"]))},t.prototype._queueRefresh=function(e,t){var r=this;this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=window.requestAnimationFrame((function(){r._refreshDecorations(),r._animationFrame=void 0})))},o([s(2,f.IBufferService),s(3,f.IDecorationService),s(4,h.IRenderService),s(5,f.IOptionsService)],t)}(u.Disposable);t.OverviewRulerRenderer=v},2950:function(e,t,r){var i=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},n=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;var o=r(4725),s=r(2585),a=function(){function e(e,t,r,i,n,o){this._textarea=e,this._compositionView=t,this._bufferService=r,this._optionsService=i,this._coreService=n,this._renderService=o,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._dataAlreadySent=""}return Object.defineProperty(e.prototype,"isComposing",{get:function(){return this._isComposing},enumerable:!1,configurable:!0}),e.prototype.compositionstart=function(){this._isComposing=!0,this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")},e.prototype.compositionupdate=function(e){var t=this;this._compositionView.textContent=e.data,this.updateCompositionElements(),setTimeout((function(){t._compositionPosition.end=t._textarea.value.length}),0)},e.prototype.compositionend=function(){this._finalizeComposition(!0)},e.prototype.keydown=function(e){if(this._isComposing||this._isSendingComposition){if(229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)},e.prototype._finalizeComposition=function(e){var t=this;if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){var r={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0,setTimeout((function(){if(t._isSendingComposition){t._isSendingComposition=!1;var e;r.start+=t._dataAlreadySent.length,(e=t._isComposing?t._textarea.value.substring(r.start,r.end):t._textarea.value.substring(r.start)).length>0&&t._coreService.triggerDataEvent(e,!0)}}),0)}else{this._isSendingComposition=!1;var i=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(i,!0)}},e.prototype._handleAnyTextareaChanges=function(){var e=this,t=this._textarea.value;setTimeout((function(){if(!e._isComposing){var r=e._textarea.value.replace(t,"");r.length>0&&(e._dataAlreadySent=r,e._coreService.triggerDataEvent(r,!0))}}),0)},e.prototype.updateCompositionElements=function(e){var t=this;if(this._isComposing){if(this._bufferService.buffer.isCursorInViewport){var r=Math.min(this._bufferService.buffer.x,this._bufferService.cols-1),i=this._renderService.dimensions.actualCellHeight,n=this._bufferService.buffer.y*this._renderService.dimensions.actualCellHeight,o=r*this._renderService.dimensions.actualCellWidth;this._compositionView.style.left=o+"px",this._compositionView.style.top=n+"px",this._compositionView.style.height=i+"px",this._compositionView.style.lineHeight=i+"px",this._compositionView.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._compositionView.style.fontSize=this._optionsService.rawOptions.fontSize+"px";var s=this._compositionView.getBoundingClientRect();this._textarea.style.left=o+"px",this._textarea.style.top=n+"px",this._textarea.style.width=Math.max(s.width,1)+"px",this._textarea.style.height=Math.max(s.height,1)+"px",this._textarea.style.lineHeight=s.height+"px"}e||setTimeout((function(){return t.updateCompositionElements(!0)}),0)}},i([n(2,s.IBufferService),n(3,s.IOptionsService),n(4,s.ICoreService),n(5,o.IRenderService)],e)}();t.CompositionHelper=a},9806:(e,t)=>{function r(e,t,r){var i=r.getBoundingClientRect(),n=e.getComputedStyle(r),o=parseInt(n.getPropertyValue("padding-left")),s=parseInt(n.getPropertyValue("padding-top"));return[t.clientX-i.left-o,t.clientY-i.top-s]}Object.defineProperty(t,"__esModule",{value:!0}),t.getRawByteCoords=t.getCoords=t.getCoordsRelativeToElement=void 0,t.getCoordsRelativeToElement=r,t.getCoords=function(e,t,i,n,o,s,a,c,l){if(s){var h=r(e,t,i);if(h)return h[0]=Math.ceil((h[0]+(l?a/2:0))/a),h[1]=Math.ceil(h[1]/c),h[0]=Math.min(Math.max(h[0],1),n+(l?1:0)),h[1]=Math.min(Math.max(h[1],1),o),h}},t.getRawByteCoords=function(e){if(e)return{x:e[0]+32,y:e[1]+32}}},9504:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.moveToCellSequence=void 0;var i=r(2584);function n(e,t,r,i){var n=e-o(r,e),a=t-o(r,t),h=Math.abs(n-a)-function(e,t,r){for(var i=0,n=e-o(r,e),a=t-o(r,t),c=0;c=0&&tt?"A":"B"}function a(e,t,r,i,n,o){for(var s=e,a=t,c="";s!==r||a!==i;)s+=n?1:-1,n&&s>o.cols-1?(c+=o.buffer.translateBufferLineToString(a,!1,e,s),s=0,e=0,a++):!n&&s<0&&(c+=o.buffer.translateBufferLineToString(a,!1,0,e+1),e=s=o.cols-1,a--);return c+o.buffer.translateBufferLineToString(a,!1,e,s)}function c(e,t){var r=t?"O":"[";return i.C0.ESC+r+e}function l(e,t){e=Math.floor(e);for(var r="",i=0;i0?i-o(s,i):t;var f=i,_=function(e,t,r,i,s,a){var c;return c=n(r,i,s,a).length>0?i-o(s,i):t,e=r&&ce?"D":"C",l(Math.abs(h-e),c(s,i));s=u>t?"D":"C";var f=Math.abs(u-t);return l(function(e,t){return t.cols-e}(u>t?e:h,r)+(f-1)*r.cols+1+((u>t?h:e)-1),c(s,i))}},4389:function(e,t,r){var i=this&&this.__assign||function(){return i=Object.assign||function(e){for(var t,r=1,i=arguments.length;r=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(t,"__esModule",{value:!0}),t.Terminal=void 0;var o=r(3236),s=r(9042),a=r(7975),c=r(7090),l=r(5741),h=r(8285),u=["cols","rows"],f=function(){function e(e){var t=this;this._core=new o.Terminal(e),this._addonManager=new l.AddonManager,this._publicOptions=i({},this._core.options);var r=function(e){return t._core.options[e]},n=function(e,r){t._checkReadonlyOptions(e),t._core.options[e]=r};for(var s in this._core.options){var a={get:r.bind(this,s),set:n.bind(this,s)};Object.defineProperty(this._publicOptions,s,a)}}return e.prototype._checkReadonlyOptions=function(e){if(u.includes(e))throw new Error('Option "'+e+'" can only be set in the constructor')},e.prototype._checkProposedApi=function(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")},Object.defineProperty(e.prototype,"onBell",{get:function(){return this._core.onBell},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onBinary",{get:function(){return this._core.onBinary},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onCursorMove",{get:function(){return this._core.onCursorMove},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onData",{get:function(){return this._core.onData},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onKey",{get:function(){return this._core.onKey},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onLineFeed",{get:function(){return this._core.onLineFeed},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onRender",{get:function(){return this._core.onRender},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onResize",{get:function(){return this._core.onResize},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onScroll",{get:function(){return this._core.onScroll},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onSelectionChange",{get:function(){return this._core.onSelectionChange},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onTitleChange",{get:function(){return this._core.onTitleChange},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onWriteParsed",{get:function(){return this._core.onWriteParsed},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"element",{get:function(){return this._core.element},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"parser",{get:function(){return this._checkProposedApi(),this._parser||(this._parser=new a.ParserApi(this._core)),this._parser},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"unicode",{get:function(){return this._checkProposedApi(),new c.UnicodeApi(this._core)},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"textarea",{get:function(){return this._core.textarea},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"rows",{get:function(){return this._core.rows},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"cols",{get:function(){return this._core.cols},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"buffer",{get:function(){return this._checkProposedApi(),this._buffer||(this._buffer=new h.BufferNamespaceApi(this._core)),this._buffer},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"markers",{get:function(){return this._checkProposedApi(),this._core.markers},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"modes",{get:function(){var e=this._core.coreService.decPrivateModes,t="none";switch(this._core.coreMouseService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,wraparoundMode:e.wraparound}},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"options",{get:function(){return this._publicOptions},set:function(e){for(var t in e)this._publicOptions[t]=e[t]},enumerable:!1,configurable:!0}),e.prototype.blur=function(){this._core.blur()},e.prototype.focus=function(){this._core.focus()},e.prototype.resize=function(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)},e.prototype.open=function(e){this._core.open(e)},e.prototype.attachCustomKeyEventHandler=function(e){this._core.attachCustomKeyEventHandler(e)},e.prototype.registerLinkMatcher=function(e,t,r){return this._checkProposedApi(),this._core.registerLinkMatcher(e,t,r)},e.prototype.deregisterLinkMatcher=function(e){this._checkProposedApi(),this._core.deregisterLinkMatcher(e)},e.prototype.registerLinkProvider=function(e){return this._checkProposedApi(),this._core.registerLinkProvider(e)},e.prototype.registerCharacterJoiner=function(e){return this._checkProposedApi(),this._core.registerCharacterJoiner(e)},e.prototype.deregisterCharacterJoiner=function(e){this._checkProposedApi(),this._core.deregisterCharacterJoiner(e)},e.prototype.registerMarker=function(e){return void 0===e&&(e=0),this._checkProposedApi(),this._verifyIntegers(e),this._core.addMarker(e)},e.prototype.registerDecoration=function(e){var t,r,i;return this._checkProposedApi(),this._verifyPositiveIntegers(null!==(t=e.x)&&void 0!==t?t:0,null!==(r=e.width)&&void 0!==r?r:0,null!==(i=e.height)&&void 0!==i?i:0),this._core.registerDecoration(e)},e.prototype.addMarker=function(e){return this.registerMarker(e)},e.prototype.hasSelection=function(){return this._core.hasSelection()},e.prototype.select=function(e,t,r){this._verifyIntegers(e,t,r),this._core.select(e,t,r)},e.prototype.getSelection=function(){return this._core.getSelection()},e.prototype.getSelectionPosition=function(){return this._core.getSelectionPosition()},e.prototype.clearSelection=function(){this._core.clearSelection()},e.prototype.selectAll=function(){this._core.selectAll()},e.prototype.selectLines=function(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)},e.prototype.dispose=function(){this._addonManager.dispose(),this._core.dispose()},e.prototype.scrollLines=function(e){this._verifyIntegers(e),this._core.scrollLines(e)},e.prototype.scrollPages=function(e){this._verifyIntegers(e),this._core.scrollPages(e)},e.prototype.scrollToTop=function(){this._core.scrollToTop()},e.prototype.scrollToBottom=function(){this._core.scrollToBottom()},e.prototype.scrollToLine=function(e){this._verifyIntegers(e),this._core.scrollToLine(e)},e.prototype.clear=function(){this._core.clear()},e.prototype.write=function(e,t){this._core.write(e,t)},e.prototype.writeUtf8=function(e,t){this._core.write(e,t)},e.prototype.writeln=function(e,t){this._core.write(e),this._core.write("\r\n",t)},e.prototype.paste=function(e){this._core.paste(e)},e.prototype.getOption=function(e){return this._core.optionsService.getOption(e)},e.prototype.setOption=function(e,t){this._checkReadonlyOptions(e),this._core.optionsService.setOption(e,t)},e.prototype.refresh=function(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)},e.prototype.reset=function(){this._core.reset()},e.prototype.clearTextureAtlas=function(){this._core.clearTextureAtlas()},e.prototype.loadAddon=function(e){return this._addonManager.loadAddon(this,e)},Object.defineProperty(e,"strings",{get:function(){return s},enumerable:!1,configurable:!0}),e.prototype._verifyIntegers=function(){for(var e,t,r=[],i=0;i=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(t,"__esModule",{value:!0}),t.BaseRenderLayer=void 0;var n=r(643),o=r(8803),s=r(1420),a=r(3734),c=r(1752),l=r(8055),h=r(9631),u=r(8978),f=function(){function e(e,t,r,i,n,o,s,a,c){this._container=e,this._alpha=i,this._colors=n,this._rendererId=o,this._bufferService=s,this._optionsService=a,this._decorationService=c,this._scaledCharWidth=0,this._scaledCharHeight=0,this._scaledCellWidth=0,this._scaledCellHeight=0,this._scaledCharLeft=0,this._scaledCharTop=0,this._columnSelectMode=!1,this._currentGlyphIdentifier={chars:"",code:0,bg:0,fg:0,bold:!1,dim:!1,italic:!1},this._canvas=document.createElement("canvas"),this._canvas.classList.add("xterm-"+t+"-layer"),this._canvas.style.zIndex=r.toString(),this._initCanvas(),this._container.appendChild(this._canvas)}return e.prototype.dispose=function(){var e;(0,h.removeElementFromParent)(this._canvas),null===(e=this._charAtlas)||void 0===e||e.dispose()},e.prototype._initCanvas=function(){this._ctx=(0,c.throwIfFalsy)(this._canvas.getContext("2d",{alpha:this._alpha})),this._alpha||this._clearAll()},e.prototype.onOptionsChanged=function(){},e.prototype.onBlur=function(){},e.prototype.onFocus=function(){},e.prototype.onCursorMove=function(){},e.prototype.onGridChanged=function(e,t){},e.prototype.onSelectionChanged=function(e,t,r){void 0===r&&(r=!1),this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=r},e.prototype.setColors=function(e){this._refreshCharAtlas(e)},e.prototype._setTransparency=function(e){if(e!==this._alpha){var t=this._canvas;this._alpha=e,this._canvas=this._canvas.cloneNode(),this._initCanvas(),this._container.replaceChild(this._canvas,t),this._refreshCharAtlas(this._colors),this.onGridChanged(0,this._bufferService.rows-1)}},e.prototype._refreshCharAtlas=function(e){this._scaledCharWidth<=0&&this._scaledCharHeight<=0||(this._charAtlas=(0,s.acquireCharAtlas)(this._optionsService.rawOptions,this._rendererId,e,this._scaledCharWidth,this._scaledCharHeight),this._charAtlas.warmUp())},e.prototype.resize=function(e){this._scaledCellWidth=e.scaledCellWidth,this._scaledCellHeight=e.scaledCellHeight,this._scaledCharWidth=e.scaledCharWidth,this._scaledCharHeight=e.scaledCharHeight,this._scaledCharLeft=e.scaledCharLeft,this._scaledCharTop=e.scaledCharTop,this._canvas.width=e.scaledCanvasWidth,this._canvas.height=e.scaledCanvasHeight,this._canvas.style.width=e.canvasWidth+"px",this._canvas.style.height=e.canvasHeight+"px",this._alpha||this._clearAll(),this._refreshCharAtlas(this._colors)},e.prototype.clearTextureAtlas=function(){var e;null===(e=this._charAtlas)||void 0===e||e.clear()},e.prototype._fillCells=function(e,t,r,i){this._ctx.fillRect(e*this._scaledCellWidth,t*this._scaledCellHeight,r*this._scaledCellWidth,i*this._scaledCellHeight)},e.prototype._fillMiddleLineAtCells=function(e,t,r){void 0===r&&(r=1);var i=Math.ceil(.5*this._scaledCellHeight);this._ctx.fillRect(e*this._scaledCellWidth,(t+1)*this._scaledCellHeight-i-window.devicePixelRatio,r*this._scaledCellWidth,window.devicePixelRatio)},e.prototype._fillBottomLineAtCells=function(e,t,r){void 0===r&&(r=1),this._ctx.fillRect(e*this._scaledCellWidth,(t+1)*this._scaledCellHeight-window.devicePixelRatio-1,r*this._scaledCellWidth,window.devicePixelRatio)},e.prototype._fillLeftLineAtCell=function(e,t,r){this._ctx.fillRect(e*this._scaledCellWidth,t*this._scaledCellHeight,window.devicePixelRatio*r,this._scaledCellHeight)},e.prototype._strokeRectAtCell=function(e,t,r,i){this._ctx.lineWidth=window.devicePixelRatio,this._ctx.strokeRect(e*this._scaledCellWidth+window.devicePixelRatio/2,t*this._scaledCellHeight+window.devicePixelRatio/2,r*this._scaledCellWidth-window.devicePixelRatio,i*this._scaledCellHeight-window.devicePixelRatio)},e.prototype._clearAll=function(){this._alpha?this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height):(this._ctx.fillStyle=this._colors.background.css,this._ctx.fillRect(0,0,this._canvas.width,this._canvas.height))},e.prototype._clearCells=function(e,t,r,i){this._alpha?this._ctx.clearRect(e*this._scaledCellWidth,t*this._scaledCellHeight,r*this._scaledCellWidth,i*this._scaledCellHeight):(this._ctx.fillStyle=this._colors.background.css,this._ctx.fillRect(e*this._scaledCellWidth,t*this._scaledCellHeight,r*this._scaledCellWidth,i*this._scaledCellHeight))},e.prototype._fillCharTrueColor=function(e,t,r){this._ctx.font=this._getFont(!1,!1),this._ctx.textBaseline=o.TEXT_BASELINE,this._clipRow(r);var i=!1;!1!==this._optionsService.rawOptions.customGlyphs&&(i=(0,u.tryDrawCustomChar)(this._ctx,e.getChars(),t*this._scaledCellWidth,r*this._scaledCellHeight,this._scaledCellWidth,this._scaledCellHeight)),i||this._ctx.fillText(e.getChars(),t*this._scaledCellWidth+this._scaledCharLeft,r*this._scaledCellHeight+this._scaledCharTop+this._scaledCharHeight)},e.prototype._drawChars=function(e,t,r){var s,a,c,l=this._getContrastColor(e,t,r);if(l||e.isFgRGB()||e.isBgRGB())this._drawUncachedChars(e,t,r,l);else{var h,u;e.isInverse()?(h=e.isBgDefault()?o.INVERTED_DEFAULT_COLOR:e.getBgColor(),u=e.isFgDefault()?o.INVERTED_DEFAULT_COLOR:e.getFgColor()):(u=e.isBgDefault()?n.DEFAULT_COLOR:e.getBgColor(),h=e.isFgDefault()?n.DEFAULT_COLOR:e.getFgColor()),h+=this._optionsService.rawOptions.drawBoldTextInBrightColors&&e.isBold()&&h<8?8:0,this._currentGlyphIdentifier.chars=e.getChars()||n.WHITESPACE_CELL_CHAR,this._currentGlyphIdentifier.code=e.getCode()||n.WHITESPACE_CELL_CODE,this._currentGlyphIdentifier.bg=u,this._currentGlyphIdentifier.fg=h,this._currentGlyphIdentifier.bold=!!e.isBold(),this._currentGlyphIdentifier.dim=!!e.isDim(),this._currentGlyphIdentifier.italic=!!e.isItalic();var f=!1;try{for(var _=i(this._decorationService.getDecorationsAtCell(t,r)),d=_.next();!d.done;d=_.next()){var p=d.value;if(p.backgroundColorRGB||p.foregroundColorRGB){f=!0;break}}}catch(e){s={error:e}}finally{try{d&&!d.done&&(a=_.return)&&a.call(_)}finally{if(s)throw s.error}}!f&&(null===(c=this._charAtlas)||void 0===c?void 0:c.draw(this._ctx,this._currentGlyphIdentifier,t*this._scaledCellWidth+this._scaledCharLeft,r*this._scaledCellHeight+this._scaledCharTop))||this._drawUncachedChars(e,t,r)}},e.prototype._drawUncachedChars=function(e,t,r,i){if(this._ctx.save(),this._ctx.font=this._getFont(!!e.isBold(),!!e.isItalic()),this._ctx.textBaseline=o.TEXT_BASELINE,e.isInverse())if(i)this._ctx.fillStyle=i.css;else if(e.isBgDefault())this._ctx.fillStyle=l.color.opaque(this._colors.background).css;else if(e.isBgRGB())this._ctx.fillStyle="rgb("+a.AttributeData.toColorRGB(e.getBgColor()).join(",")+")";else{var n=e.getBgColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&e.isBold()&&n<8&&(n+=8),this._ctx.fillStyle=this._colors.ansi[n].css}else if(i)this._ctx.fillStyle=i.css;else if(e.isFgDefault())this._ctx.fillStyle=this._colors.foreground.css;else if(e.isFgRGB())this._ctx.fillStyle="rgb("+a.AttributeData.toColorRGB(e.getFgColor()).join(",")+")";else{var s=e.getFgColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&e.isBold()&&s<8&&(s+=8),this._ctx.fillStyle=this._colors.ansi[s].css}this._clipRow(r),e.isDim()&&(this._ctx.globalAlpha=o.DIM_OPACITY);var c=!1;!1!==this._optionsService.rawOptions.customGlyphs&&(c=(0,u.tryDrawCustomChar)(this._ctx,e.getChars(),t*this._scaledCellWidth,r*this._scaledCellHeight,this._scaledCellWidth,this._scaledCellHeight)),c||this._ctx.fillText(e.getChars(),t*this._scaledCellWidth+this._scaledCharLeft,r*this._scaledCellHeight+this._scaledCharTop+this._scaledCharHeight),this._ctx.restore()},e.prototype._clipRow=function(e){this._ctx.beginPath(),this._ctx.rect(0,e*this._scaledCellHeight,this._bufferService.cols*this._scaledCellWidth,this._scaledCellHeight),this._ctx.clip()},e.prototype._getFont=function(e,t){return(t?"italic":"")+" "+(e?this._optionsService.rawOptions.fontWeightBold:this._optionsService.rawOptions.fontWeight)+" "+this._optionsService.rawOptions.fontSize*window.devicePixelRatio+"px "+this._optionsService.rawOptions.fontFamily},e.prototype._getContrastColor=function(e,t,r){var n,o,s,a,h=!1;try{for(var u=i(this._decorationService.getDecorationsAtCell(t,r)),f=u.next();!f.done;f=u.next()){var _=f.value;"top"!==_.options.layer&&h||(_.backgroundColorRGB&&(s=_.backgroundColorRGB.rgba),_.foregroundColorRGB&&(a=_.foregroundColorRGB.rgba),h="top"===_.options.layer)}}catch(e){n={error:e}}finally{try{f&&!f.done&&(o=u.return)&&o.call(u)}finally{if(n)throw n.error}}if(h||this._colors.selectionForeground&&this._isCellInSelection(t,r)&&(a=this._colors.selectionForeground.rgba),s||a||1!==this._optionsService.rawOptions.minimumContrastRatio&&!(0,c.excludeFromContrastRatioDemands)(e.getCode())){if(!s&&!a){var d=this._colors.contrastCache.getColor(e.bg,e.fg);if(void 0!==d)return d||void 0}var p=e.getFgColor(),v=e.getFgColorMode(),y=e.getBgColor(),g=e.getBgColorMode(),m=!!e.isInverse(),b=!!e.isInverse();if(m){var S=p;p=y,y=S;var C=v;v=g,g=C}var w=this._resolveBackgroundRgba(void 0!==s?50331648:g,null!=s?s:y,m),L=this._resolveForegroundRgba(v,p,m,b),E=l.rgba.ensureContrastRatio(null!=s?s:w,null!=a?a:L,this._optionsService.rawOptions.minimumContrastRatio);if(!E){if(!a)return void this._colors.contrastCache.setColor(e.bg,e.fg,null);E=a}var x={css:l.channels.toCss(E>>24&255,E>>16&255,E>>8&255),rgba:E};return s||a||this._colors.contrastCache.setColor(e.bg,e.fg,x),x}},e.prototype._resolveBackgroundRgba=function(e,t,r){switch(e){case 16777216:case 33554432:return this._colors.ansi[t].rgba;case 50331648:return t<<8;default:return r?this._colors.foreground.rgba:this._colors.background.rgba}},e.prototype._resolveForegroundRgba=function(e,t,r,i){switch(e){case 16777216:case 33554432:return this._optionsService.rawOptions.drawBoldTextInBrightColors&&i&&t<8&&(t+=8),this._colors.ansi[t].rgba;case 50331648:return t<<8;default:return r?this._colors.background.rgba:this._colors.foreground.rgba}},e.prototype._isCellInSelection=function(e,t){var r=this._selectionStart,i=this._selectionEnd;return!(!r||!i)&&(this._columnSelectMode?e>=r[0]&&t>=r[1]&&er[1]&&t=r[0]&&e=r[0])},e}();t.BaseRenderLayer=f},2512:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)}),o=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},s=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CursorRenderLayer=void 0;var a=r(1546),c=r(511),l=r(2585),h=r(4725),u=600,f=function(e){function t(t,r,i,n,o,s,a,l,h,u){var f=e.call(this,t,"cursor",r,!0,i,n,s,a,u)||this;return f._onRequestRedraw=o,f._coreService=l,f._coreBrowserService=h,f._cell=new c.CellData,f._state={x:0,y:0,isFocused:!1,style:"",width:0},f._cursorRenderers={bar:f._renderBarCursor.bind(f),block:f._renderBlockCursor.bind(f),underline:f._renderUnderlineCursor.bind(f)},f}return n(t,e),t.prototype.dispose=function(){this._cursorBlinkStateManager&&(this._cursorBlinkStateManager.dispose(),this._cursorBlinkStateManager=void 0),e.prototype.dispose.call(this)},t.prototype.resize=function(t){e.prototype.resize.call(this,t),this._state={x:0,y:0,isFocused:!1,style:"",width:0}},t.prototype.reset=function(){var e;this._clearCursor(),null===(e=this._cursorBlinkStateManager)||void 0===e||e.restartBlinkAnimation(),this.onOptionsChanged()},t.prototype.onBlur=function(){var e;null===(e=this._cursorBlinkStateManager)||void 0===e||e.pause(),this._onRequestRedraw.fire({start:this._bufferService.buffer.y,end:this._bufferService.buffer.y})},t.prototype.onFocus=function(){var e;null===(e=this._cursorBlinkStateManager)||void 0===e||e.resume(),this._onRequestRedraw.fire({start:this._bufferService.buffer.y,end:this._bufferService.buffer.y})},t.prototype.onOptionsChanged=function(){var e,t=this;this._optionsService.rawOptions.cursorBlink?this._cursorBlinkStateManager||(this._cursorBlinkStateManager=new _(this._coreBrowserService.isFocused,(function(){t._render(!0)}))):(null===(e=this._cursorBlinkStateManager)||void 0===e||e.dispose(),this._cursorBlinkStateManager=void 0),this._onRequestRedraw.fire({start:this._bufferService.buffer.y,end:this._bufferService.buffer.y})},t.prototype.onCursorMove=function(){var e;null===(e=this._cursorBlinkStateManager)||void 0===e||e.restartBlinkAnimation()},t.prototype.onGridChanged=function(e,t){!this._cursorBlinkStateManager||this._cursorBlinkStateManager.isPaused?this._render(!1):this._cursorBlinkStateManager.restartBlinkAnimation()},t.prototype._render=function(e){if(this._coreService.isCursorInitialized&&!this._coreService.isCursorHidden){var t=this._bufferService.buffer.ybase+this._bufferService.buffer.y,r=t-this._bufferService.buffer.ydisp;if(r<0||r>=this._bufferService.rows)this._clearCursor();else{var i=Math.min(this._bufferService.buffer.x,this._bufferService.cols-1);if(this._bufferService.buffer.lines.get(t).loadCell(i,this._cell),void 0!==this._cell.content){if(!this._coreBrowserService.isFocused){this._clearCursor(),this._ctx.save(),this._ctx.fillStyle=this._colors.cursor.css;var n=this._optionsService.rawOptions.cursorStyle;return n&&"block"!==n?this._cursorRenderers[n](i,r,this._cell):this._renderBlurCursor(i,r,this._cell),this._ctx.restore(),this._state.x=i,this._state.y=r,this._state.isFocused=!1,this._state.style=n,void(this._state.width=this._cell.getWidth())}if(!this._cursorBlinkStateManager||this._cursorBlinkStateManager.isCursorVisible){if(this._state){if(this._state.x===i&&this._state.y===r&&this._state.isFocused===this._coreBrowserService.isFocused&&this._state.style===this._optionsService.rawOptions.cursorStyle&&this._state.width===this._cell.getWidth())return;this._clearCursor()}this._ctx.save(),this._cursorRenderers[this._optionsService.rawOptions.cursorStyle||"block"](i,r,this._cell),this._ctx.restore(),this._state.x=i,this._state.y=r,this._state.isFocused=!1,this._state.style=this._optionsService.rawOptions.cursorStyle,this._state.width=this._cell.getWidth()}else this._clearCursor()}}}else this._clearCursor()},t.prototype._clearCursor=function(){this._state&&(window.devicePixelRatio<1?this._clearAll():this._clearCells(this._state.x,this._state.y,this._state.width,1),this._state={x:0,y:0,isFocused:!1,style:"",width:0})},t.prototype._renderBarCursor=function(e,t,r){this._ctx.save(),this._ctx.fillStyle=this._colors.cursor.css,this._fillLeftLineAtCell(e,t,this._optionsService.rawOptions.cursorWidth),this._ctx.restore()},t.prototype._renderBlockCursor=function(e,t,r){this._ctx.save(),this._ctx.fillStyle=this._colors.cursor.css,this._fillCells(e,t,r.getWidth(),1),this._ctx.fillStyle=this._colors.cursorAccent.css,this._fillCharTrueColor(r,e,t),this._ctx.restore()},t.prototype._renderUnderlineCursor=function(e,t,r){this._ctx.save(),this._ctx.fillStyle=this._colors.cursor.css,this._fillBottomLineAtCells(e,t),this._ctx.restore()},t.prototype._renderBlurCursor=function(e,t,r){this._ctx.save(),this._ctx.strokeStyle=this._colors.cursor.css,this._strokeRectAtCell(e,t,r.getWidth(),1),this._ctx.restore()},o([s(5,l.IBufferService),s(6,l.IOptionsService),s(7,l.ICoreService),s(8,h.ICoreBrowserService),s(9,l.IDecorationService)],t)}(a.BaseRenderLayer);t.CursorRenderLayer=f;var _=function(){function e(e,t){this._renderCallback=t,this.isCursorVisible=!0,e&&this._restartInterval()}return Object.defineProperty(e.prototype,"isPaused",{get:function(){return!(this._blinkStartTimeout||this._blinkInterval)},enumerable:!1,configurable:!0}),e.prototype.dispose=function(){this._blinkInterval&&(window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)},e.prototype.restartBlinkAnimation=function(){var e=this;this.isPaused||(this._animationTimeRestarted=Date.now(),this.isCursorVisible=!0,this._animationFrame||(this._animationFrame=window.requestAnimationFrame((function(){e._renderCallback(),e._animationFrame=void 0}))))},e.prototype._restartInterval=function(e){var t=this;void 0===e&&(e=u),this._blinkInterval&&(window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout=window.setTimeout((function(){if(t._animationTimeRestarted){var e=u-(Date.now()-t._animationTimeRestarted);if(t._animationTimeRestarted=void 0,e>0)return void t._restartInterval(e)}t.isCursorVisible=!1,t._animationFrame=window.requestAnimationFrame((function(){t._renderCallback(),t._animationFrame=void 0})),t._blinkInterval=window.setInterval((function(){if(t._animationTimeRestarted){var e=u-(Date.now()-t._animationTimeRestarted);return t._animationTimeRestarted=void 0,void t._restartInterval(e)}t.isCursorVisible=!t.isCursorVisible,t._animationFrame=window.requestAnimationFrame((function(){t._renderCallback(),t._animationFrame=void 0}))}),u)}),e)},e.prototype.pause=function(){this.isCursorVisible=!0,this._blinkInterval&&(window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)},e.prototype.resume=function(){this.pause(),this._animationTimeRestarted=void 0,this._restartInterval(),this.restartBlinkAnimation()},e}()},8978:function(e,t,r){var i,n,o,s,a,c,l,h,u,f,_,d,p,v,y,g,m,b,S,C,w,L,E,x,R,k,M,A,O,D,T,B,P,I,H,j,F,W,U,q,N,z,K,G,V,X,Z,Y,J,$,Q,ee,te,re,ie,ne,oe,se,ae,ce,le,he,ue,fe,_e,de,pe,ve,ye,ge,me,be,Se,Ce,we,Le,Ee,xe,Re,ke,Me,Ae,Oe,De,Te,Be,Pe,Ie,He,je,Fe,We,Ue,qe,Ne,ze,Ke,Ge,Ve,Xe,Ze,Ye,Je,$e,Qe,et,tt,rt,it,nt,ot,st,at,ct,lt,ht,ut,ft,_t,dt,pt,vt,yt,gt,mt,bt,St,Ct,wt=this&&this.__read||function(e,t){var r="function"==typeof Symbol&&e[Symbol.iterator];if(!r)return e;var i,n,o=r.call(e),s=[];try{for(;(void 0===t||t-- >0)&&!(i=o.next()).done;)s.push(i.value)}catch(e){n={error:e}}finally{try{i&&!i.done&&(r=o.return)&&r.call(o)}finally{if(n)throw n.error}}return s},Lt=this&&this.__values||function(e){var t="function"==typeof Symbol&&Symbol.iterator,r=t&&e[t],i=0;if(r)return r.call(e);if(e&&"number"==typeof e.length)return{next:function(){return e&&i>=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(t,"__esModule",{value:!0}),t.tryDrawCustomChar=t.powerlineDefinitions=t.boxDrawingDefinitions=t.blockElementDefinitions=void 0;var Et=r(1752);t.blockElementDefinitions={"▀":[{x:0,y:0,w:8,h:4}],"▁":[{x:0,y:7,w:8,h:1}],"▂":[{x:0,y:6,w:8,h:2}],"▃":[{x:0,y:5,w:8,h:3}],"▄":[{x:0,y:4,w:8,h:4}],"▅":[{x:0,y:3,w:8,h:5}],"▆":[{x:0,y:2,w:8,h:6}],"▇":[{x:0,y:1,w:8,h:7}],"█":[{x:0,y:0,w:8,h:8}],"▉":[{x:0,y:0,w:7,h:8}],"▊":[{x:0,y:0,w:6,h:8}],"▋":[{x:0,y:0,w:5,h:8}],"▌":[{x:0,y:0,w:4,h:8}],"▍":[{x:0,y:0,w:3,h:8}],"▎":[{x:0,y:0,w:2,h:8}],"▏":[{x:0,y:0,w:1,h:8}],"▐":[{x:4,y:0,w:4,h:8}],"▔":[{x:0,y:0,w:9,h:1}],"▕":[{x:7,y:0,w:1,h:8}],"▖":[{x:0,y:4,w:4,h:4}],"▗":[{x:4,y:4,w:4,h:4}],"▘":[{x:0,y:0,w:4,h:4}],"▙":[{x:0,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}],"▚":[{x:0,y:0,w:4,h:4},{x:4,y:4,w:4,h:4}],"▛":[{x:0,y:0,w:4,h:8},{x:0,y:0,w:4,h:8}],"▜":[{x:0,y:0,w:8,h:4},{x:4,y:0,w:4,h:8}],"▝":[{x:4,y:0,w:4,h:4}],"▞":[{x:4,y:0,w:4,h:4},{x:0,y:4,w:4,h:4}],"▟":[{x:4,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}],"🭰":[{x:1,y:0,w:1,h:8}],"🭱":[{x:2,y:0,w:1,h:8}],"🭲":[{x:3,y:0,w:1,h:8}],"🭳":[{x:4,y:0,w:1,h:8}],"🭴":[{x:5,y:0,w:1,h:8}],"🭵":[{x:6,y:0,w:1,h:8}],"🭶":[{x:0,y:1,w:8,h:1}],"🭷":[{x:0,y:2,w:8,h:1}],"🭸":[{x:0,y:3,w:8,h:1}],"🭹":[{x:0,y:4,w:8,h:1}],"🭺":[{x:0,y:5,w:8,h:1}],"🭻":[{x:0,y:6,w:8,h:1}],"🭼":[{x:0,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}],"🭽":[{x:0,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}],"🭾":[{x:7,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}],"🭿":[{x:7,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}],"🮀":[{x:0,y:0,w:8,h:1},{x:0,y:7,w:8,h:1}],"🮁":[{x:0,y:0,w:8,h:1},{x:0,y:2,w:8,h:1},{x:0,y:4,w:8,h:1},{x:0,y:7,w:8,h:1}],"🮂":[{x:0,y:0,w:8,h:2}],"🮃":[{x:0,y:0,w:8,h:3}],"🮄":[{x:0,y:0,w:8,h:5}],"🮅":[{x:0,y:0,w:8,h:6}],"🮆":[{x:0,y:0,w:8,h:7}],"🮇":[{x:6,y:0,w:2,h:8}],"🮈":[{x:5,y:0,w:3,h:8}],"🮉":[{x:3,y:0,w:5,h:8}],"🮊":[{x:2,y:0,w:6,h:8}],"🮋":[{x:1,y:0,w:7,h:8}],"🮕":[{x:0,y:0,w:2,h:2},{x:4,y:0,w:2,h:2},{x:2,y:2,w:2,h:2},{x:6,y:2,w:2,h:2},{x:0,y:4,w:2,h:2},{x:4,y:4,w:2,h:2},{x:2,y:6,w:2,h:2},{x:6,y:6,w:2,h:2}],"🮖":[{x:2,y:0,w:2,h:2},{x:6,y:0,w:2,h:2},{x:0,y:2,w:2,h:2},{x:4,y:2,w:2,h:2},{x:2,y:4,w:2,h:2},{x:6,y:4,w:2,h:2},{x:0,y:6,w:2,h:2},{x:4,y:6,w:2,h:2}],"🮗":[{x:0,y:2,w:8,h:2},{x:0,y:6,w:8,h:2}]};var xt={"░":[[1,0,0,0],[0,0,0,0],[0,0,1,0],[0,0,0,0]],"▒":[[1,0],[0,0],[0,1],[0,0]],"▓":[[0,1],[1,1],[1,0],[1,1]]};t.boxDrawingDefinitions={"─":(i={},i[1]="M0,.5 L1,.5",i),"━":(n={},n[3]="M0,.5 L1,.5",n),"│":(o={},o[1]="M.5,0 L.5,1",o),"┃":(s={},s[3]="M.5,0 L.5,1",s),"┌":(a={},a[1]="M0.5,1 L.5,.5 L1,.5",a),"┏":(c={},c[3]="M0.5,1 L.5,.5 L1,.5",c),"┐":(l={},l[1]="M0,.5 L.5,.5 L.5,1",l),"┓":(h={},h[3]="M0,.5 L.5,.5 L.5,1",h),"└":(u={},u[1]="M.5,0 L.5,.5 L1,.5",u),"┗":(f={},f[3]="M.5,0 L.5,.5 L1,.5",f),"┘":(_={},_[1]="M.5,0 L.5,.5 L0,.5",_),"┛":(d={},d[3]="M.5,0 L.5,.5 L0,.5",d),"├":(p={},p[1]="M.5,0 L.5,1 M.5,.5 L1,.5",p),"┣":(v={},v[3]="M.5,0 L.5,1 M.5,.5 L1,.5",v),"┤":(y={},y[1]="M.5,0 L.5,1 M.5,.5 L0,.5",y),"┫":(g={},g[3]="M.5,0 L.5,1 M.5,.5 L0,.5",g),"┬":(m={},m[1]="M0,.5 L1,.5 M.5,.5 L.5,1",m),"┳":(b={},b[3]="M0,.5 L1,.5 M.5,.5 L.5,1",b),"┴":(S={},S[1]="M0,.5 L1,.5 M.5,.5 L.5,0",S),"┻":(C={},C[3]="M0,.5 L1,.5 M.5,.5 L.5,0",C),"┼":(w={},w[1]="M0,.5 L1,.5 M.5,0 L.5,1",w),"╋":(L={},L[3]="M0,.5 L1,.5 M.5,0 L.5,1",L),"╴":(E={},E[1]="M.5,.5 L0,.5",E),"╸":(x={},x[3]="M.5,.5 L0,.5",x),"╵":(R={},R[1]="M.5,.5 L.5,0",R),"╹":(k={},k[3]="M.5,.5 L.5,0",k),"╶":(M={},M[1]="M.5,.5 L1,.5",M),"╺":(A={},A[3]="M.5,.5 L1,.5",A),"╷":(O={},O[1]="M.5,.5 L.5,1",O),"╻":(D={},D[3]="M.5,.5 L.5,1",D),"═":(T={},T[1]=function(e,t){return"M0,"+(.5-t)+" L1,"+(.5-t)+" M0,"+(.5+t)+" L1,"+(.5+t)},T),"║":(B={},B[1]=function(e,t){return"M"+(.5-e)+",0 L"+(.5-e)+",1 M"+(.5+e)+",0 L"+(.5+e)+",1"},B),"╒":(P={},P[1]=function(e,t){return"M.5,1 L.5,"+(.5-t)+" L1,"+(.5-t)+" M.5,"+(.5+t)+" L1,"+(.5+t)},P),"╓":(I={},I[1]=function(e,t){return"M"+(.5-e)+",1 L"+(.5-e)+",.5 L1,.5 M"+(.5+e)+",.5 L"+(.5+e)+",1"},I),"╔":(H={},H[1]=function(e,t){return"M1,"+(.5-t)+" L"+(.5-e)+","+(.5-t)+" L"+(.5-e)+",1 M1,"+(.5+t)+" L"+(.5+e)+","+(.5+t)+" L"+(.5+e)+",1"},H),"╕":(j={},j[1]=function(e,t){return"M0,"+(.5-t)+" L.5,"+(.5-t)+" L.5,1 M0,"+(.5+t)+" L.5,"+(.5+t)},j),"╖":(F={},F[1]=function(e,t){return"M"+(.5+e)+",1 L"+(.5+e)+",.5 L0,.5 M"+(.5-e)+",.5 L"+(.5-e)+",1"},F),"╗":(W={},W[1]=function(e,t){return"M0,"+(.5+t)+" L"+(.5-e)+","+(.5+t)+" L"+(.5-e)+",1 M0,"+(.5-t)+" L"+(.5+e)+","+(.5-t)+" L"+(.5+e)+",1"},W),"╘":(U={},U[1]=function(e,t){return"M.5,0 L.5,"+(.5+t)+" L1,"+(.5+t)+" M.5,"+(.5-t)+" L1,"+(.5-t)},U),"╙":(q={},q[1]=function(e,t){return"M1,.5 L"+(.5-e)+",.5 L"+(.5-e)+",0 M"+(.5+e)+",.5 L"+(.5+e)+",0"},q),"╚":(N={},N[1]=function(e,t){return"M1,"+(.5-t)+" L"+(.5+e)+","+(.5-t)+" L"+(.5+e)+",0 M1,"+(.5+t)+" L"+(.5-e)+","+(.5+t)+" L"+(.5-e)+",0"},N),"╛":(z={},z[1]=function(e,t){return"M0,"+(.5+t)+" L.5,"+(.5+t)+" L.5,0 M0,"+(.5-t)+" L.5,"+(.5-t)},z),"╜":(K={},K[1]=function(e,t){return"M0,.5 L"+(.5+e)+",.5 L"+(.5+e)+",0 M"+(.5-e)+",.5 L"+(.5-e)+",0"},K),"╝":(G={},G[1]=function(e,t){return"M0,"+(.5-t)+" L"+(.5-e)+","+(.5-t)+" L"+(.5-e)+",0 M0,"+(.5+t)+" L"+(.5+e)+","+(.5+t)+" L"+(.5+e)+",0"},G),"╞":(V={},V[1]=function(e,t){return"M.5,0 L.5,1 M.5,"+(.5-t)+" L1,"+(.5-t)+" M.5,"+(.5+t)+" L1,"+(.5+t)},V),"╟":(X={},X[1]=function(e,t){return"M"+(.5-e)+",0 L"+(.5-e)+",1 M"+(.5+e)+",0 L"+(.5+e)+",1 M"+(.5+e)+",.5 L1,.5"},X),"╠":(Z={},Z[1]=function(e,t){return"M"+(.5-e)+",0 L"+(.5-e)+",1 M1,"+(.5+t)+" L"+(.5+e)+","+(.5+t)+" L"+(.5+e)+",1 M1,"+(.5-t)+" L"+(.5+e)+","+(.5-t)+" L"+(.5+e)+",0"},Z),"╡":(Y={},Y[1]=function(e,t){return"M.5,0 L.5,1 M0,"+(.5-t)+" L.5,"+(.5-t)+" M0,"+(.5+t)+" L.5,"+(.5+t)},Y),"╢":(J={},J[1]=function(e,t){return"M0,.5 L"+(.5-e)+",.5 M"+(.5-e)+",0 L"+(.5-e)+",1 M"+(.5+e)+",0 L"+(.5+e)+",1"},J),"╣":($={},$[1]=function(e,t){return"M"+(.5+e)+",0 L"+(.5+e)+",1 M0,"+(.5+t)+" L"+(.5-e)+","+(.5+t)+" L"+(.5-e)+",1 M0,"+(.5-t)+" L"+(.5-e)+","+(.5-t)+" L"+(.5-e)+",0"},$),"╤":(Q={},Q[1]=function(e,t){return"M0,"+(.5-t)+" L1,"+(.5-t)+" M0,"+(.5+t)+" L1,"+(.5+t)+" M.5,"+(.5+t)+" L.5,1"},Q),"╥":(ee={},ee[1]=function(e,t){return"M0,.5 L1,.5 M"+(.5-e)+",.5 L"+(.5-e)+",1 M"+(.5+e)+",.5 L"+(.5+e)+",1"},ee),"╦":(te={},te[1]=function(e,t){return"M0,"+(.5-t)+" L1,"+(.5-t)+" M0,"+(.5+t)+" L"+(.5-e)+","+(.5+t)+" L"+(.5-e)+",1 M1,"+(.5+t)+" L"+(.5+e)+","+(.5+t)+" L"+(.5+e)+",1"},te),"╧":(re={},re[1]=function(e,t){return"M.5,0 L.5,"+(.5-t)+" M0,"+(.5-t)+" L1,"+(.5-t)+" M0,"+(.5+t)+" L1,"+(.5+t)},re),"╨":(ie={},ie[1]=function(e,t){return"M0,.5 L1,.5 M"+(.5-e)+",.5 L"+(.5-e)+",0 M"+(.5+e)+",.5 L"+(.5+e)+",0"},ie),"╩":(ne={},ne[1]=function(e,t){return"M0,"+(.5+t)+" L1,"+(.5+t)+" M0,"+(.5-t)+" L"+(.5-e)+","+(.5-t)+" L"+(.5-e)+",0 M1,"+(.5-t)+" L"+(.5+e)+","+(.5-t)+" L"+(.5+e)+",0"},ne),"╪":(oe={},oe[1]=function(e,t){return"M.5,0 L.5,1 M0,"+(.5-t)+" L1,"+(.5-t)+" M0,"+(.5+t)+" L1,"+(.5+t)},oe),"╫":(se={},se[1]=function(e,t){return"M0,.5 L1,.5 M"+(.5-e)+",0 L"+(.5-e)+",1 M"+(.5+e)+",0 L"+(.5+e)+",1"},se),"╬":(ae={},ae[1]=function(e,t){return"M0,"+(.5+t)+" L"+(.5-e)+","+(.5+t)+" L"+(.5-e)+",1 M1,"+(.5+t)+" L"+(.5+e)+","+(.5+t)+" L"+(.5+e)+",1 M0,"+(.5-t)+" L"+(.5-e)+","+(.5-t)+" L"+(.5-e)+",0 M1,"+(.5-t)+" L"+(.5+e)+","+(.5-t)+" L"+(.5+e)+",0"},ae),"╱":(ce={},ce[1]="M1,0 L0,1",ce),"╲":(le={},le[1]="M0,0 L1,1",le),"╳":(he={},he[1]="M1,0 L0,1 M0,0 L1,1",he),"╼":(ue={},ue[1]="M.5,.5 L0,.5",ue[3]="M.5,.5 L1,.5",ue),"╽":(fe={},fe[1]="M.5,.5 L.5,0",fe[3]="M.5,.5 L.5,1",fe),"╾":(_e={},_e[1]="M.5,.5 L1,.5",_e[3]="M.5,.5 L0,.5",_e),"╿":(de={},de[1]="M.5,.5 L.5,1",de[3]="M.5,.5 L.5,0",de),"┍":(pe={},pe[1]="M.5,.5 L.5,1",pe[3]="M.5,.5 L1,.5",pe),"┎":(ve={},ve[1]="M.5,.5 L1,.5",ve[3]="M.5,.5 L.5,1",ve),"┑":(ye={},ye[1]="M.5,.5 L.5,1",ye[3]="M.5,.5 L0,.5",ye),"┒":(ge={},ge[1]="M.5,.5 L0,.5",ge[3]="M.5,.5 L.5,1",ge),"┕":(me={},me[1]="M.5,.5 L.5,0",me[3]="M.5,.5 L1,.5",me),"┖":(be={},be[1]="M.5,.5 L1,.5",be[3]="M.5,.5 L.5,0",be),"┙":(Se={},Se[1]="M.5,.5 L.5,0",Se[3]="M.5,.5 L0,.5",Se),"┚":(Ce={},Ce[1]="M.5,.5 L0,.5",Ce[3]="M.5,.5 L.5,0",Ce),"┝":(we={},we[1]="M.5,0 L.5,1",we[3]="M.5,.5 L1,.5",we),"┞":(Le={},Le[1]="M0.5,1 L.5,.5 L1,.5",Le[3]="M.5,.5 L.5,0",Le),"┟":(Ee={},Ee[1]="M.5,0 L.5,.5 L1,.5",Ee[3]="M.5,.5 L.5,1",Ee),"┠":(xe={},xe[1]="M.5,.5 L1,.5",xe[3]="M.5,0 L.5,1",xe),"┡":(Re={},Re[1]="M.5,.5 L.5,1",Re[3]="M.5,0 L.5,.5 L1,.5",Re),"┢":(ke={},ke[1]="M.5,.5 L.5,0",ke[3]="M0.5,1 L.5,.5 L1,.5",ke),"┥":(Me={},Me[1]="M.5,0 L.5,1",Me[3]="M.5,.5 L0,.5",Me),"┦":(Ae={},Ae[1]="M0,.5 L.5,.5 L.5,1",Ae[3]="M.5,.5 L.5,0",Ae),"┧":(Oe={},Oe[1]="M.5,0 L.5,.5 L0,.5",Oe[3]="M.5,.5 L.5,1",Oe),"┨":(De={},De[1]="M.5,.5 L0,.5",De[3]="M.5,0 L.5,1",De),"┩":(Te={},Te[1]="M.5,.5 L.5,1",Te[3]="M.5,0 L.5,.5 L0,.5",Te),"┪":(Be={},Be[1]="M.5,.5 L.5,0",Be[3]="M0,.5 L.5,.5 L.5,1",Be),"┭":(Pe={},Pe[1]="M0.5,1 L.5,.5 L1,.5",Pe[3]="M.5,.5 L0,.5",Pe),"┮":(Ie={},Ie[1]="M0,.5 L.5,.5 L.5,1",Ie[3]="M.5,.5 L1,.5",Ie),"┯":(He={},He[1]="M.5,.5 L.5,1",He[3]="M0,.5 L1,.5",He),"┰":(je={},je[1]="M0,.5 L1,.5",je[3]="M.5,.5 L.5,1",je),"┱":(Fe={},Fe[1]="M.5,.5 L1,.5",Fe[3]="M0,.5 L.5,.5 L.5,1",Fe),"┲":(We={},We[1]="M.5,.5 L0,.5",We[3]="M0.5,1 L.5,.5 L1,.5",We),"┵":(Ue={},Ue[1]="M.5,0 L.5,.5 L1,.5",Ue[3]="M.5,.5 L0,.5",Ue),"┶":(qe={},qe[1]="M.5,0 L.5,.5 L0,.5",qe[3]="M.5,.5 L1,.5",qe),"┷":(Ne={},Ne[1]="M.5,.5 L.5,0",Ne[3]="M0,.5 L1,.5",Ne),"┸":(ze={},ze[1]="M0,.5 L1,.5",ze[3]="M.5,.5 L.5,0",ze),"┹":(Ke={},Ke[1]="M.5,.5 L1,.5",Ke[3]="M.5,0 L.5,.5 L0,.5",Ke),"┺":(Ge={},Ge[1]="M.5,.5 L0,.5",Ge[3]="M.5,0 L.5,.5 L1,.5",Ge),"┽":(Ve={},Ve[1]="M.5,0 L.5,1 M.5,.5 L1,.5",Ve[3]="M.5,.5 L0,.5",Ve),"┾":(Xe={},Xe[1]="M.5,0 L.5,1 M.5,.5 L0,.5",Xe[3]="M.5,.5 L1,.5",Xe),"┿":(Ze={},Ze[1]="M.5,0 L.5,1",Ze[3]="M0,.5 L1,.5",Ze),"╀":(Ye={},Ye[1]="M0,.5 L1,.5 M.5,.5 L.5,1",Ye[3]="M.5,.5 L.5,0",Ye),"╁":(Je={},Je[1]="M.5,.5 L.5,0 M0,.5 L1,.5",Je[3]="M.5,.5 L.5,1",Je),"╂":($e={},$e[1]="M0,.5 L1,.5",$e[3]="M.5,0 L.5,1",$e),"╃":(Qe={},Qe[1]="M0.5,1 L.5,.5 L1,.5",Qe[3]="M.5,0 L.5,.5 L0,.5",Qe),"╄":(et={},et[1]="M0,.5 L.5,.5 L.5,1",et[3]="M.5,0 L.5,.5 L1,.5",et),"╅":(tt={},tt[1]="M.5,0 L.5,.5 L1,.5",tt[3]="M0,.5 L.5,.5 L.5,1",tt),"╆":(rt={},rt[1]="M.5,0 L.5,.5 L0,.5",rt[3]="M0.5,1 L.5,.5 L1,.5",rt),"╇":(it={},it[1]="M.5,.5 L.5,1",it[3]="M.5,.5 L.5,0 M0,.5 L1,.5",it),"╈":(nt={},nt[1]="M.5,.5 L.5,0",nt[3]="M0,.5 L1,.5 M.5,.5 L.5,1",nt),"╉":(ot={},ot[1]="M.5,.5 L1,.5",ot[3]="M.5,0 L.5,1 M.5,.5 L0,.5",ot),"╊":(st={},st[1]="M.5,.5 L0,.5",st[3]="M.5,0 L.5,1 M.5,.5 L1,.5",st),"╌":(at={},at[1]="M.1,.5 L.4,.5 M.6,.5 L.9,.5",at),"╍":(ct={},ct[3]="M.1,.5 L.4,.5 M.6,.5 L.9,.5",ct),"┄":(lt={},lt[1]="M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",lt),"┅":(ht={},ht[3]="M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",ht),"┈":(ut={},ut[1]="M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",ut),"┉":(ft={},ft[3]="M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",ft),"╎":(_t={},_t[1]="M.5,.1 L.5,.4 M.5,.6 L.5,.9",_t),"╏":(dt={},dt[3]="M.5,.1 L.5,.4 M.5,.6 L.5,.9",dt),"┆":(pt={},pt[1]="M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",pt),"┇":(vt={},vt[3]="M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",vt),"┊":(yt={},yt[1]="M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",yt),"┋":(gt={},gt[3]="M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",gt),"╭":(mt={},mt[1]="C.5,1,.5,.5,1,.5",mt),"╮":(bt={},bt[1]="C.5,1,.5,.5,0,.5",bt),"╯":(St={},St[1]="C.5,0,.5,.5,0,.5",St),"╰":(Ct={},Ct[1]="C.5,0,.5,.5,1,.5",Ct)},t.powerlineDefinitions={"":{d:"M0,0 L1,.5 L0,1",type:0},"":{d:"M0,0 L1,.5 L0,1",type:1,horizontalPadding:.5},"":{d:"M1,0 L0,.5 L1,1",type:0},"":{d:"M1,0 L0,.5 L1,1",type:1,horizontalPadding:.5}},t.tryDrawCustomChar=function(e,r,i,n,o,s){var a=t.blockElementDefinitions[r];if(a)return function(e,t,r,i,n,o){for(var s=0;s7&&parseInt(c.slice(7,9),16)||1;else{if(!c.startsWith("rgba"))throw new Error('Unexpected fillStyle color format "'+c+'" when drawing pattern glyph');p=(s=wt(c.substring(5,c.length-1).split(",").map((function(e){return parseFloat(e)})),4))[0],v=s[1],y=s[2],g=s[3]}for(var m=0;m{Object.defineProperty(t,"__esModule",{value:!0}),t.GridCache=void 0;var r=function(){function e(){this.cache=[]}return e.prototype.resize=function(e,t){for(var r=0;r=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},s=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.LinkRenderLayer=void 0;var a=r(1546),c=r(8803),l=r(2040),h=r(2585),u=function(e){function t(t,r,i,n,o,s,a,c,l){var h=e.call(this,t,"link",r,!0,i,n,a,c,l)||this;return o.onShowLinkUnderline((function(e){return h._onShowLinkUnderline(e)})),o.onHideLinkUnderline((function(e){return h._onHideLinkUnderline(e)})),s.onShowLinkUnderline((function(e){return h._onShowLinkUnderline(e)})),s.onHideLinkUnderline((function(e){return h._onHideLinkUnderline(e)})),h}return n(t,e),t.prototype.resize=function(t){e.prototype.resize.call(this,t),this._state=void 0},t.prototype.reset=function(){this._clearCurrentLink()},t.prototype._clearCurrentLink=function(){if(this._state){this._clearCells(this._state.x1,this._state.y1,this._state.cols-this._state.x1,1);var e=this._state.y2-this._state.y1-1;e>0&&this._clearCells(0,this._state.y1+1,this._state.cols,e),this._clearCells(0,this._state.y2,this._state.x2,1),this._state=void 0}},t.prototype._onShowLinkUnderline=function(e){if(e.fg===c.INVERTED_DEFAULT_COLOR?this._ctx.fillStyle=this._colors.background.css:e.fg&&(0,l.is256Color)(e.fg)?this._ctx.fillStyle=this._colors.ansi[e.fg].css:this._ctx.fillStyle=this._colors.foreground.css,e.y1===e.y2)this._fillBottomLineAtCells(e.x1,e.y1,e.x2-e.x1);else{this._fillBottomLineAtCells(e.x1,e.y1,e.cols-e.x1);for(var t=e.y1+1;t=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},s=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}},a=this&&this.__values||function(e){var t="function"==typeof Symbol&&Symbol.iterator,r=t&&e[t],i=0;if(r)return r.call(e);if(e&&"number"==typeof e.length)return{next:function(){return e&&i>=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(t,"__esModule",{value:!0}),t.Renderer=void 0;var c=r(9596),l=r(4149),h=r(2512),u=r(5098),f=r(844),_=r(4725),d=r(2585),p=r(1420),v=r(8460),y=1,g=function(e){function t(t,r,i,n,o,s,a,f){var _=e.call(this)||this;_._colors=t,_._screenElement=r,_._bufferService=s,_._charSizeService=a,_._optionsService=f,_._id=y++,_._onRequestRedraw=new v.EventEmitter;var d=_._optionsService.rawOptions.allowTransparency;return _._renderLayers=[o.createInstance(c.TextRenderLayer,_._screenElement,0,_._colors,d,_._id),o.createInstance(l.SelectionRenderLayer,_._screenElement,1,_._colors,_._id),o.createInstance(u.LinkRenderLayer,_._screenElement,2,_._colors,_._id,i,n),o.createInstance(h.CursorRenderLayer,_._screenElement,3,_._colors,_._id,_._onRequestRedraw)],_.dimensions={scaledCharWidth:0,scaledCharHeight:0,scaledCellWidth:0,scaledCellHeight:0,scaledCharLeft:0,scaledCharTop:0,scaledCanvasWidth:0,scaledCanvasHeight:0,canvasWidth:0,canvasHeight:0,actualCellWidth:0,actualCellHeight:0},_._devicePixelRatio=window.devicePixelRatio,_._updateDimensions(),_.onOptionsChanged(),_}return n(t,e),Object.defineProperty(t.prototype,"onRequestRedraw",{get:function(){return this._onRequestRedraw.event},enumerable:!1,configurable:!0}),t.prototype.dispose=function(){var t,r;try{for(var i=a(this._renderLayers),n=i.next();!n.done;n=i.next())n.value.dispose()}catch(e){t={error:e}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(t)throw t.error}}e.prototype.dispose.call(this),(0,p.removeTerminalFromCache)(this._id)},t.prototype.onDevicePixelRatioChange=function(){this._devicePixelRatio!==window.devicePixelRatio&&(this._devicePixelRatio=window.devicePixelRatio,this.onResize(this._bufferService.cols,this._bufferService.rows))},t.prototype.setColors=function(e){var t,r;this._colors=e;try{for(var i=a(this._renderLayers),n=i.next();!n.done;n=i.next()){var o=n.value;o.setColors(this._colors),o.reset()}}catch(e){t={error:e}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(t)throw t.error}}},t.prototype.onResize=function(e,t){var r,i;this._updateDimensions();try{for(var n=a(this._renderLayers),o=n.next();!o.done;o=n.next())o.value.resize(this.dimensions)}catch(e){r={error:e}}finally{try{o&&!o.done&&(i=n.return)&&i.call(n)}finally{if(r)throw r.error}}this._screenElement.style.width=this.dimensions.canvasWidth+"px",this._screenElement.style.height=this.dimensions.canvasHeight+"px"},t.prototype.onCharSizeChanged=function(){this.onResize(this._bufferService.cols,this._bufferService.rows)},t.prototype.onBlur=function(){this._runOperation((function(e){return e.onBlur()}))},t.prototype.onFocus=function(){this._runOperation((function(e){return e.onFocus()}))},t.prototype.onSelectionChanged=function(e,t,r){void 0===r&&(r=!1),this._runOperation((function(i){return i.onSelectionChanged(e,t,r)})),this._colors.selectionForeground&&this._onRequestRedraw.fire({start:0,end:this._bufferService.rows-1})},t.prototype.onCursorMove=function(){this._runOperation((function(e){return e.onCursorMove()}))},t.prototype.onOptionsChanged=function(){this._runOperation((function(e){return e.onOptionsChanged()}))},t.prototype.clear=function(){this._runOperation((function(e){return e.reset()}))},t.prototype._runOperation=function(e){var t,r;try{for(var i=a(this._renderLayers),n=i.next();!n.done;n=i.next())e(n.value)}catch(e){t={error:e}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(t)throw t.error}}},t.prototype.renderRows=function(e,t){var r,i;try{for(var n=a(this._renderLayers),o=n.next();!o.done;o=n.next())o.value.onGridChanged(e,t)}catch(e){r={error:e}}finally{try{o&&!o.done&&(i=n.return)&&i.call(n)}finally{if(r)throw r.error}}},t.prototype.clearTextureAtlas=function(){var e,t;try{for(var r=a(this._renderLayers),i=r.next();!i.done;i=r.next())i.value.clearTextureAtlas()}catch(t){e={error:t}}finally{try{i&&!i.done&&(t=r.return)&&t.call(r)}finally{if(e)throw e.error}}},t.prototype._updateDimensions=function(){this._charSizeService.hasValidSize&&(this.dimensions.scaledCharWidth=Math.floor(this._charSizeService.width*window.devicePixelRatio),this.dimensions.scaledCharHeight=Math.ceil(this._charSizeService.height*window.devicePixelRatio),this.dimensions.scaledCellHeight=Math.floor(this.dimensions.scaledCharHeight*this._optionsService.rawOptions.lineHeight),this.dimensions.scaledCharTop=1===this._optionsService.rawOptions.lineHeight?0:Math.round((this.dimensions.scaledCellHeight-this.dimensions.scaledCharHeight)/2),this.dimensions.scaledCellWidth=this.dimensions.scaledCharWidth+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.scaledCharLeft=Math.floor(this._optionsService.rawOptions.letterSpacing/2),this.dimensions.scaledCanvasHeight=this._bufferService.rows*this.dimensions.scaledCellHeight,this.dimensions.scaledCanvasWidth=this._bufferService.cols*this.dimensions.scaledCellWidth,this.dimensions.canvasHeight=Math.round(this.dimensions.scaledCanvasHeight/window.devicePixelRatio),this.dimensions.canvasWidth=Math.round(this.dimensions.scaledCanvasWidth/window.devicePixelRatio),this.dimensions.actualCellHeight=this.dimensions.canvasHeight/this._bufferService.rows,this.dimensions.actualCellWidth=this.dimensions.canvasWidth/this._bufferService.cols)},o([s(4,d.IInstantiationService),s(5,d.IBufferService),s(6,_.ICharSizeService),s(7,d.IOptionsService)],t)}(f.Disposable);t.Renderer=g},1752:(e,t)=>{function r(e){return 57508<=e&&e<=57558}Object.defineProperty(t,"__esModule",{value:!0}),t.excludeFromContrastRatioDemands=t.isPowerlineGlyph=t.throwIfFalsy=void 0,t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=r,t.excludeFromContrastRatioDemands=function(e){return r(e)||function(e){return 9472<=e&&e<=9631}(e)}},4149:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)}),o=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},s=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionRenderLayer=void 0;var a=r(1546),c=r(2585),l=function(e){function t(t,r,i,n,o,s,a){var c=e.call(this,t,"selection",r,!0,i,n,o,s,a)||this;return c._clearState(),c}return n(t,e),t.prototype._clearState=function(){this._state={start:void 0,end:void 0,columnSelectMode:void 0,ydisp:void 0}},t.prototype.resize=function(t){e.prototype.resize.call(this,t),this._clearState()},t.prototype.reset=function(){this._state.start&&this._state.end&&(this._clearState(),this._clearAll())},t.prototype.onSelectionChanged=function(t,r,i){if(e.prototype.onSelectionChanged.call(this,t,r,i),this._didStateChange(t,r,i,this._bufferService.buffer.ydisp))if(this._clearAll(),t&&r){var n=t[1]-this._bufferService.buffer.ydisp,o=r[1]-this._bufferService.buffer.ydisp,s=Math.max(n,0),a=Math.min(o,this._bufferService.rows-1);if(s>=this._bufferService.rows||a<0)this._state.ydisp=this._bufferService.buffer.ydisp;else{if(this._ctx.fillStyle=this._colors.selectionTransparent.css,i){var c=t[0],l=r[0]-c,h=a-s+1;this._fillCells(c,s,l,h)}else{c=n===s?t[0]:0;var u=s===o?r[0]:this._bufferService.cols;this._fillCells(c,s,u-c,1);var f=Math.max(a-s-1,0);if(this._fillCells(0,s+1,this._bufferService.cols,f),s!==a){var _=o===a?r[0]:this._bufferService.cols;this._fillCells(0,a,_,1)}}this._state.start=[t[0],t[1]],this._state.end=[r[0],r[1]],this._state.columnSelectMode=i,this._state.ydisp=this._bufferService.buffer.ydisp}}else this._clearState()},t.prototype._didStateChange=function(e,t,r,i){return!this._areCoordinatesEqual(e,this._state.start)||!this._areCoordinatesEqual(t,this._state.end)||r!==this._state.columnSelectMode||i!==this._state.ydisp},t.prototype._areCoordinatesEqual=function(e,t){return!(!e||!t)&&e[0]===t[0]&&e[1]===t[1]},o([s(4,c.IBufferService),s(5,c.IOptionsService),s(6,c.IDecorationService)],t)}(a.BaseRenderLayer);t.SelectionRenderLayer=l},9596:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)}),o=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},s=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}},a=this&&this.__values||function(e){var t="function"==typeof Symbol&&Symbol.iterator,r=t&&e[t],i=0;if(r)return r.call(e);if(e&&"number"==typeof e.length)return{next:function(){return e&&i>=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(t,"__esModule",{value:!0}),t.TextRenderLayer=void 0;var c=r(3700),l=r(1546),h=r(3734),u=r(643),f=r(511),_=r(2585),d=r(4725),p=r(4269),v=function(e){function t(t,r,i,n,o,s,a,l,h){var u=e.call(this,t,"text",r,n,i,o,s,a,h)||this;return u._characterJoinerService=l,u._characterWidth=0,u._characterFont="",u._characterOverlapCache={},u._workCell=new f.CellData,u._state=new c.GridCache,u}return n(t,e),t.prototype.resize=function(t){e.prototype.resize.call(this,t);var r=this._getFont(!1,!1);this._characterWidth===t.scaledCharWidth&&this._characterFont===r||(this._characterWidth=t.scaledCharWidth,this._characterFont=r,this._characterOverlapCache={}),this._state.clear(),this._state.resize(this._bufferService.cols,this._bufferService.rows)},t.prototype.reset=function(){this._state.clear(),this._clearAll()},t.prototype._forEachCell=function(e,t,r){for(var i=e;i<=t;i++)for(var n=i+this._bufferService.buffer.ydisp,o=this._bufferService.buffer.lines.get(n),s=this._characterJoinerService.getJoinedCharacters(n),a=0;a0&&a===s[0][0]){l=!0;var f=s.shift();c=new p.JoinedCellData(this._workCell,o.translateToString(!0,f[0],f[1]),f[1]-f[0]),h=f[1]-1}!l&&this._isOverlapping(c)&&hthis._characterWidth;return this._ctx.restore(),this._characterOverlapCache[t]=r,r},o([s(5,_.IBufferService),s(6,_.IOptionsService),s(7,d.ICharacterJoinerService),s(8,_.IDecorationService)],t)}(l.BaseRenderLayer);t.TextRenderLayer=v},9616:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BaseCharAtlas=void 0;var r=function(){function e(){this._didWarmUp=!1}return e.prototype.dispose=function(){},e.prototype.warmUp=function(){this._didWarmUp||(this._doWarmUp(),this._didWarmUp=!0)},e.prototype._doWarmUp=function(){},e.prototype.clear=function(){},e.prototype.beginFrame=function(){},e}();t.BaseCharAtlas=r},1420:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.removeTerminalFromCache=t.acquireCharAtlas=void 0;var i=r(2040),n=r(1906),o=[];t.acquireCharAtlas=function(e,t,r,s,a){for(var c=(0,i.generateConfig)(s,a,e,r),l=0;l=0){if((0,i.configEquals)(u.config,c))return u.atlas;1===u.ownedBy.length?(u.atlas.dispose(),o.splice(l,1)):u.ownedBy.splice(h,1);break}}for(l=0;l{Object.defineProperty(t,"__esModule",{value:!0}),t.is256Color=t.configEquals=t.generateConfig=void 0;var i=r(643);t.generateConfig=function(e,t,r,i){var n={foreground:i.foreground,background:i.background,cursor:void 0,cursorAccent:void 0,selection:void 0,ansi:i.ansi.slice()};return{devicePixelRatio:window.devicePixelRatio,scaledCharWidth:e,scaledCharHeight:t,fontFamily:r.fontFamily,fontSize:r.fontSize,fontWeight:r.fontWeight,fontWeightBold:r.fontWeightBold,allowTransparency:r.allowTransparency,colors:n}},t.configEquals=function(e,t){for(var r=0;r{Object.defineProperty(t,"__esModule",{value:!0}),t.CHAR_ATLAS_CELL_SPACING=t.TEXT_BASELINE=t.DIM_OPACITY=t.INVERTED_DEFAULT_COLOR=void 0;var i=r(6114);t.INVERTED_DEFAULT_COLOR=257,t.DIM_OPACITY=.5,t.TEXT_BASELINE=i.isFirefox||i.isLegacyEdge?"bottom":"ideographic",t.CHAR_ATLAS_CELL_SPACING=1},1906:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)});Object.defineProperty(t,"__esModule",{value:!0}),t.NoneCharAtlas=t.DynamicCharAtlas=t.getGlyphCacheKey=void 0;var o=r(8803),s=r(9616),a=r(5680),c=r(7001),l=r(6114),h=r(1752),u=r(8055),f=1024,_=1024,d={css:"rgba(0, 0, 0, 0)",rgba:0};function p(e){return e.code<<21|e.bg<<12|e.fg<<3|(e.bold?0:4)+(e.dim?0:2)+(e.italic?0:1)}t.getGlyphCacheKey=p;var v=function(e){function t(t,r){var i=e.call(this)||this;i._config=r,i._drawToCacheCount=0,i._glyphsWaitingOnBitmap=[],i._bitmapCommitTimeout=null,i._bitmap=null,i._cacheCanvas=t.createElement("canvas"),i._cacheCanvas.width=f,i._cacheCanvas.height=_,i._cacheCtx=(0,h.throwIfFalsy)(i._cacheCanvas.getContext("2d",{alpha:!0}));var n=t.createElement("canvas");n.width=i._config.scaledCharWidth,n.height=i._config.scaledCharHeight,i._tmpCtx=(0,h.throwIfFalsy)(n.getContext("2d",{alpha:i._config.allowTransparency})),i._width=Math.floor(f/i._config.scaledCharWidth),i._height=Math.floor(_/i._config.scaledCharHeight);var o=i._width*i._height;return i._cacheMap=new c.LRUMap(o),i._cacheMap.prealloc(o),i}return n(t,e),t.prototype.dispose=function(){null!==this._bitmapCommitTimeout&&(window.clearTimeout(this._bitmapCommitTimeout),this._bitmapCommitTimeout=null)},t.prototype.beginFrame=function(){this._drawToCacheCount=0},t.prototype.clear=function(){if(this._cacheMap.size>0){var e=this._width*this._height;this._cacheMap=new c.LRUMap(e),this._cacheMap.prealloc(e)}this._cacheCtx.clearRect(0,0,f,_),this._tmpCtx.clearRect(0,0,this._config.scaledCharWidth,this._config.scaledCharHeight)},t.prototype.draw=function(e,t,r,i){if(32===t.code)return!0;if(!this._canCache(t))return!1;var n=p(t),o=this._cacheMap.get(n);if(null!=o)return this._drawFromCache(e,o,r,i),!0;if(this._drawToCacheCount<100){var s;s=this._cacheMap.size>>24,n=t.rgba>>>16&255,o=t.rgba>>>8&255,s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.LRUMap=void 0;var r=function(){function e(e){this.capacity=e,this._map={},this._head=null,this._tail=null,this._nodePool=[],this.size=0}return e.prototype._unlinkNode=function(e){var t=e.prev,r=e.next;e===this._head&&(this._head=r),e===this._tail&&(this._tail=t),null!==t&&(t.next=r),null!==r&&(r.prev=t)},e.prototype._appendNode=function(e){var t=this._tail;null!==t&&(t.next=e),e.prev=t,e.next=null,this._tail=e,null===this._head&&(this._head=e)},e.prototype.prealloc=function(e){for(var t=this._nodePool,r=0;r=this.capacity)r=this._head,this._unlinkNode(r),delete this._map[r.key],r.key=e,r.value=t,this._map[e]=r;else{var i=this._nodePool;i.length>0?((r=i.pop()).key=e,r.value=t):r={prev:null,next:null,key:e,value:t},this._map[e]=r,this.size++}this._appendNode(r)},e}();t.LRUMap=r},1296:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)}),o=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},s=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}},a=this&&this.__values||function(e){var t="function"==typeof Symbol&&Symbol.iterator,r=t&&e[t],i=0;if(r)return r.call(e);if(e&&"number"==typeof e.length)return{next:function(){return e&&i>=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;var c=r(3787),l=r(8803),h=r(844),u=r(4725),f=r(2585),_=r(8460),d=r(8055),p=r(9631),v="xterm-dom-renderer-owner-",y="xterm-fg-",g="xterm-bg-",m="xterm-focus",b=1,S=function(e){function t(t,r,i,n,o,s,a,l,h,u){var f=e.call(this)||this;return f._colors=t,f._element=r,f._screenElement=i,f._viewportElement=n,f._linkifier=o,f._linkifier2=s,f._charSizeService=l,f._optionsService=h,f._bufferService=u,f._terminalClass=b++,f._rowElements=[],f._rowContainer=document.createElement("div"),f._rowContainer.classList.add("xterm-rows"),f._rowContainer.style.lineHeight="normal",f._rowContainer.setAttribute("aria-hidden","true"),f._refreshRowElements(f._bufferService.cols,f._bufferService.rows),f._selectionContainer=document.createElement("div"),f._selectionContainer.classList.add("xterm-selection"),f._selectionContainer.setAttribute("aria-hidden","true"),f.dimensions={scaledCharWidth:0,scaledCharHeight:0,scaledCellWidth:0,scaledCellHeight:0,scaledCharLeft:0,scaledCharTop:0,scaledCanvasWidth:0,scaledCanvasHeight:0,canvasWidth:0,canvasHeight:0,actualCellWidth:0,actualCellHeight:0},f._updateDimensions(),f._injectCss(),f._rowFactory=a.createInstance(c.DomRendererRowFactory,document,f._colors),f._element.classList.add(v+f._terminalClass),f._screenElement.appendChild(f._rowContainer),f._screenElement.appendChild(f._selectionContainer),f.register(f._linkifier.onShowLinkUnderline((function(e){return f._onLinkHover(e)}))),f.register(f._linkifier.onHideLinkUnderline((function(e){return f._onLinkLeave(e)}))),f.register(f._linkifier2.onShowLinkUnderline((function(e){return f._onLinkHover(e)}))),f.register(f._linkifier2.onHideLinkUnderline((function(e){return f._onLinkLeave(e)}))),f}return n(t,e),Object.defineProperty(t.prototype,"onRequestRedraw",{get:function(){return(new _.EventEmitter).event},enumerable:!1,configurable:!0}),t.prototype.dispose=function(){this._element.classList.remove(v+this._terminalClass),(0,p.removeElementFromParent)(this._rowContainer,this._selectionContainer,this._themeStyleElement,this._dimensionsStyleElement),e.prototype.dispose.call(this)},t.prototype._updateDimensions=function(){var e,t;this.dimensions.scaledCharWidth=this._charSizeService.width*window.devicePixelRatio,this.dimensions.scaledCharHeight=Math.ceil(this._charSizeService.height*window.devicePixelRatio),this.dimensions.scaledCellWidth=this.dimensions.scaledCharWidth+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.scaledCellHeight=Math.floor(this.dimensions.scaledCharHeight*this._optionsService.rawOptions.lineHeight),this.dimensions.scaledCharLeft=0,this.dimensions.scaledCharTop=0,this.dimensions.scaledCanvasWidth=this.dimensions.scaledCellWidth*this._bufferService.cols,this.dimensions.scaledCanvasHeight=this.dimensions.scaledCellHeight*this._bufferService.rows,this.dimensions.canvasWidth=Math.round(this.dimensions.scaledCanvasWidth/window.devicePixelRatio),this.dimensions.canvasHeight=Math.round(this.dimensions.scaledCanvasHeight/window.devicePixelRatio),this.dimensions.actualCellWidth=this.dimensions.canvasWidth/this._bufferService.cols,this.dimensions.actualCellHeight=this.dimensions.canvasHeight/this._bufferService.rows;try{for(var r=a(this._rowElements),i=r.next();!i.done;i=r.next()){var n=i.value;n.style.width=this.dimensions.canvasWidth+"px",n.style.height=this.dimensions.actualCellHeight+"px",n.style.lineHeight=this.dimensions.actualCellHeight+"px",n.style.overflow="hidden"}}catch(t){e={error:t}}finally{try{i&&!i.done&&(t=r.return)&&t.call(r)}finally{if(e)throw e.error}}this._dimensionsStyleElement||(this._dimensionsStyleElement=document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));var o=this._terminalSelector+" .xterm-rows span { display: inline-block; height: 100%; vertical-align: top; width: "+this.dimensions.actualCellWidth+"px}";this._dimensionsStyleElement.textContent=o,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=this.dimensions.canvasWidth+"px",this._screenElement.style.height=this.dimensions.canvasHeight+"px"},t.prototype.setColors=function(e){this._colors=e,this._injectCss()},t.prototype._injectCss=function(){var e=this;this._themeStyleElement||(this._themeStyleElement=document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));var t=this._terminalSelector+" .xterm-rows { color: "+this._colors.foreground.css+"; font-family: "+this._optionsService.rawOptions.fontFamily+"; font-size: "+this._optionsService.rawOptions.fontSize+"px;}";t+=this._terminalSelector+" span:not(."+c.BOLD_CLASS+") { font-weight: "+this._optionsService.rawOptions.fontWeight+";}"+this._terminalSelector+" span."+c.BOLD_CLASS+" { font-weight: "+this._optionsService.rawOptions.fontWeightBold+";}"+this._terminalSelector+" span."+c.ITALIC_CLASS+" { font-style: italic;}",t+="@keyframes blink_box_shadow_"+this._terminalClass+" { 50% { box-shadow: none; }}",t+="@keyframes blink_block_"+this._terminalClass+" { 0% { background-color: "+this._colors.cursor.css+"; color: "+this._colors.cursorAccent.css+"; } 50% { background-color: "+this._colors.cursorAccent.css+"; color: "+this._colors.cursor.css+"; }}",t+=this._terminalSelector+" .xterm-rows:not(.xterm-focus) ."+c.CURSOR_CLASS+"."+c.CURSOR_STYLE_BLOCK_CLASS+" { outline: 1px solid "+this._colors.cursor.css+"; outline-offset: -1px;}"+this._terminalSelector+" .xterm-rows.xterm-focus ."+c.CURSOR_CLASS+"."+c.CURSOR_BLINK_CLASS+":not(."+c.CURSOR_STYLE_BLOCK_CLASS+") { animation: blink_box_shadow_"+this._terminalClass+" 1s step-end infinite;}"+this._terminalSelector+" .xterm-rows.xterm-focus ."+c.CURSOR_CLASS+"."+c.CURSOR_BLINK_CLASS+"."+c.CURSOR_STYLE_BLOCK_CLASS+" { animation: blink_block_"+this._terminalClass+" 1s step-end infinite;}"+this._terminalSelector+" .xterm-rows.xterm-focus ."+c.CURSOR_CLASS+"."+c.CURSOR_STYLE_BLOCK_CLASS+" { background-color: "+this._colors.cursor.css+"; color: "+this._colors.cursorAccent.css+";}"+this._terminalSelector+" .xterm-rows ."+c.CURSOR_CLASS+"."+c.CURSOR_STYLE_BAR_CLASS+" { box-shadow: "+this._optionsService.rawOptions.cursorWidth+"px 0 0 "+this._colors.cursor.css+" inset;}"+this._terminalSelector+" .xterm-rows ."+c.CURSOR_CLASS+"."+c.CURSOR_STYLE_UNDERLINE_CLASS+" { box-shadow: 0 -1px 0 "+this._colors.cursor.css+" inset;}",t+=this._terminalSelector+" .xterm-selection { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}"+this._terminalSelector+" .xterm-selection div { position: absolute; background-color: "+this._colors.selectionOpaque.css+";}",this._colors.ansi.forEach((function(r,i){t+=e._terminalSelector+" ."+y+i+" { color: "+r.css+"; }"+e._terminalSelector+" ."+g+i+" { background-color: "+r.css+"; }"})),t+=this._terminalSelector+" ."+y+l.INVERTED_DEFAULT_COLOR+" { color: "+d.color.opaque(this._colors.background).css+"; }"+this._terminalSelector+" ."+g+l.INVERTED_DEFAULT_COLOR+" { background-color: "+this._colors.foreground.css+"; }",this._themeStyleElement.textContent=t},t.prototype.onDevicePixelRatioChange=function(){this._updateDimensions()},t.prototype._refreshRowElements=function(e,t){for(var r=this._rowElements.length;r<=t;r++){var i=document.createElement("div");this._rowContainer.appendChild(i),this._rowElements.push(i)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop())},t.prototype.onResize=function(e,t){this._refreshRowElements(e,t),this._updateDimensions()},t.prototype.onCharSizeChanged=function(){this._updateDimensions()},t.prototype.onBlur=function(){this._rowContainer.classList.remove(m)},t.prototype.onFocus=function(){this._rowContainer.classList.add(m)},t.prototype.onSelectionChanged=function(e,t,r){for(;this._selectionContainer.children.length;)this._selectionContainer.removeChild(this._selectionContainer.children[0]);if(this._rowFactory.onSelectionChanged(e,t,r),this.renderRows(0,this._bufferService.rows-1),e&&t){var i=e[1]-this._bufferService.buffer.ydisp,n=t[1]-this._bufferService.buffer.ydisp,o=Math.max(i,0),s=Math.min(n,this._bufferService.rows-1);if(!(o>=this._bufferService.rows||s<0)){var a=document.createDocumentFragment();if(r){var c=e[0]>t[0];a.appendChild(this._createSelectionElement(o,c?t[0]:e[0],c?e[0]:t[0],s-o+1))}else{var l=i===o?e[0]:0,h=o===n?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(o,l,h));var u=s-o-1;if(a.appendChild(this._createSelectionElement(o+1,0,this._bufferService.cols,u)),o!==s){var f=n===s?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(s,0,f))}}this._selectionContainer.appendChild(a)}}},t.prototype._createSelectionElement=function(e,t,r,i){void 0===i&&(i=1);var n=document.createElement("div");return n.style.height=i*this.dimensions.actualCellHeight+"px",n.style.top=e*this.dimensions.actualCellHeight+"px",n.style.left=t*this.dimensions.actualCellWidth+"px",n.style.width=this.dimensions.actualCellWidth*(r-t)+"px",n},t.prototype.onCursorMove=function(){},t.prototype.onOptionsChanged=function(){this._updateDimensions(),this._injectCss()},t.prototype.clear=function(){var e,t;try{for(var r=a(this._rowElements),i=r.next();!i.done;i=r.next())i.value.innerText=""}catch(t){e={error:t}}finally{try{i&&!i.done&&(t=r.return)&&t.call(r)}finally{if(e)throw e.error}}},t.prototype.renderRows=function(e,t){for(var r=this._bufferService.buffer.ybase+this._bufferService.buffer.y,i=Math.min(this._bufferService.buffer.x,this._bufferService.cols-1),n=this._optionsService.rawOptions.cursorBlink,o=e;o<=t;o++){var s=this._rowElements[o];s.innerText="";var a=o+this._bufferService.buffer.ydisp,c=this._bufferService.buffer.lines.get(a),l=this._optionsService.rawOptions.cursorStyle;s.appendChild(this._rowFactory.createRow(c,a,a===r,l,i,n,this.dimensions.actualCellWidth,this._bufferService.cols))}},Object.defineProperty(t.prototype,"_terminalSelector",{get:function(){return"."+v+this._terminalClass},enumerable:!1,configurable:!0}),t.prototype._onLinkHover=function(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)},t.prototype._onLinkLeave=function(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)},t.prototype._setCellUnderline=function(e,t,r,i,n,o){for(;e!==t||r!==i;){var s=this._rowElements[r];if(!s)return;var a=s.children[e];a&&(a.style.textDecoration=o?"underline":"none"),++e>=n&&(e=0,r++)}},o([s(6,f.IInstantiationService),s(7,u.ICharSizeService),s(8,f.IOptionsService),s(9,f.IBufferService)],t)}(h.Disposable);t.DomRenderer=S},3787:function(e,t,r){var i=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},n=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}},o=this&&this.__values||function(e){var t="function"==typeof Symbol&&Symbol.iterator,r=t&&e[t],i=0;if(r)return r.call(e);if(e&&"number"==typeof e.length)return{next:function(){return e&&i>=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=t.CURSOR_STYLE_UNDERLINE_CLASS=t.CURSOR_STYLE_BAR_CLASS=t.CURSOR_STYLE_BLOCK_CLASS=t.CURSOR_BLINK_CLASS=t.CURSOR_CLASS=t.STRIKETHROUGH_CLASS=t.UNDERLINE_CLASS=t.ITALIC_CLASS=t.DIM_CLASS=t.BOLD_CLASS=void 0;var s=r(8803),a=r(643),c=r(511),l=r(2585),h=r(8055),u=r(4725),f=r(4269),_=r(1752);t.BOLD_CLASS="xterm-bold",t.DIM_CLASS="xterm-dim",t.ITALIC_CLASS="xterm-italic",t.UNDERLINE_CLASS="xterm-underline",t.STRIKETHROUGH_CLASS="xterm-strikethrough",t.CURSOR_CLASS="xterm-cursor",t.CURSOR_BLINK_CLASS="xterm-cursor-blink",t.CURSOR_STYLE_BLOCK_CLASS="xterm-cursor-block",t.CURSOR_STYLE_BAR_CLASS="xterm-cursor-bar",t.CURSOR_STYLE_UNDERLINE_CLASS="xterm-cursor-underline";var d=function(){function e(e,t,r,i,n,o){this._document=e,this._colors=t,this._characterJoinerService=r,this._optionsService=i,this._coreService=n,this._decorationService=o,this._workCell=new c.CellData,this._columnSelectMode=!1}return e.prototype.setColors=function(e){this._colors=e},e.prototype.onSelectionChanged=function(e,t,r){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=r},e.prototype.createRow=function(e,r,i,n,c,l,u,_){for(var d,v,y=this._document.createDocumentFragment(),g=this._characterJoinerService.getJoinedCharacters(r),m=0,b=Math.min(e.length,_)-1;b>=0;b--)if(e.loadCell(b,this._workCell).getCode()!==a.NULL_CELL_CODE||i&&b===c){m=b+1;break}for(b=0;b0&&b===g[0][0]){C=!0;var E=g.shift();L=new f.JoinedCellData(this._workCell,e.translateToString(!0,E[0],E[1]),E[1]-E[0]),w=E[1]-1,S=L.getWidth()}var x=this._document.createElement("span");if(S>1&&(x.style.width=u*S+"px"),C&&(x.style.display="inline",c>=b&&c<=w&&(c=b)),!this._coreService.isCursorHidden&&i&&b===c)switch(x.classList.add(t.CURSOR_CLASS),l&&x.classList.add(t.CURSOR_BLINK_CLASS),n){case"bar":x.classList.add(t.CURSOR_STYLE_BAR_CLASS);break;case"underline":x.classList.add(t.CURSOR_STYLE_UNDERLINE_CLASS);break;default:x.classList.add(t.CURSOR_STYLE_BLOCK_CLASS)}L.isBold()&&x.classList.add(t.BOLD_CLASS),L.isItalic()&&x.classList.add(t.ITALIC_CLASS),L.isDim()&&x.classList.add(t.DIM_CLASS),L.isUnderline()&&x.classList.add(t.UNDERLINE_CLASS),L.isInvisible()?x.textContent=a.WHITESPACE_CELL_CHAR:x.textContent=L.getChars()||a.WHITESPACE_CELL_CHAR,L.isStrikethrough()&&x.classList.add(t.STRIKETHROUGH_CLASS);var R=L.getFgColor(),k=L.getFgColorMode(),M=L.getBgColor(),A=L.getBgColorMode(),O=!!L.isInverse();if(O){var D=R;R=M,M=D;var T=k;k=A,A=T}var B=void 0,P=void 0,I=!1;try{for(var H=(d=void 0,o(this._decorationService.getDecorationsAtCell(b,r))),j=H.next();!j.done;j=H.next()){var F=j.value;"top"!==F.options.layer&&I||(F.backgroundColorRGB&&(A=50331648,M=F.backgroundColorRGB.rgba>>8&16777215,B=F.backgroundColorRGB),F.foregroundColorRGB&&(k=50331648,R=F.foregroundColorRGB.rgba>>8&16777215,P=F.foregroundColorRGB),I="top"===F.options.layer)}}catch(e){d={error:e}}finally{try{j&&!j.done&&(v=H.return)&&v.call(H)}finally{if(d)throw d.error}}var W=this._isCellInSelection(b,r);I||this._colors.selectionForeground&&W&&(k=50331648,R=this._colors.selectionForeground.rgba>>8&16777215,P=this._colors.selectionForeground),W&&(B=this._colors.selectionOpaque,I=!0),I&&x.classList.add("xterm-decoration-top");var U=void 0;switch(A){case 16777216:case 33554432:U=this._colors.ansi[M],x.classList.add("xterm-bg-"+M);break;case 50331648:U=h.rgba.toColor(M>>16,M>>8&255,255&M),this._addStyle(x,"background-color:#"+p((M>>>0).toString(16),"0",6));break;default:O?(U=this._colors.foreground,x.classList.add("xterm-bg-"+s.INVERTED_DEFAULT_COLOR)):U=this._colors.background}switch(k){case 16777216:case 33554432:L.isBold()&&R<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&(R+=8),this._applyMinimumContrast(x,U,this._colors.ansi[R],L,B,void 0)||x.classList.add("xterm-fg-"+R);break;case 50331648:var q=h.rgba.toColor(R>>16&255,R>>8&255,255&R);this._applyMinimumContrast(x,U,q,L,B,P)||this._addStyle(x,"color:#"+p(R.toString(16),"0",6));break;default:this._applyMinimumContrast(x,U,this._colors.foreground,L,B,void 0)||O&&x.classList.add("xterm-fg-"+s.INVERTED_DEFAULT_COLOR)}y.appendChild(x),b=w}}return y},e.prototype._applyMinimumContrast=function(e,t,r,i,n,o){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,_.excludeFromContrastRatioDemands)(i.getCode()))return!1;var s=void 0;return n||o||(s=this._colors.contrastCache.getColor(t.rgba,r.rgba)),void 0===s&&(s=h.color.ensureContrastRatio(n||t,o||r,this._optionsService.rawOptions.minimumContrastRatio),this._colors.contrastCache.setColor((n||t).rgba,(o||r).rgba,null!=s?s:null)),!!s&&(this._addStyle(e,"color:"+s.css),!0)},e.prototype._addStyle=function(e,t){e.setAttribute("style",""+(e.getAttribute("style")||"")+t+";")},e.prototype._isCellInSelection=function(e,t){var r=this._selectionStart,i=this._selectionEnd;return!(!r||!i)&&(this._columnSelectMode?r[0]<=i[0]?e>=r[0]&&t>=r[1]&&e=r[1]&&e>=i[0]&&t<=i[1]:t>r[1]&&t=r[0]&&e=r[0])},i([n(2,u.ICharacterJoinerService),n(3,l.IOptionsService),n(4,l.ICoreService),n(5,l.IDecorationService)],e)}();function p(e,t,r){for(;e.length{Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0;var r=function(){function e(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}return e.prototype.clearSelection=function(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0},Object.defineProperty(e.prototype,"finalSelectionStart",{get:function(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"finalSelectionEnd",{get:function(){return this.isSelectAllActive?[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1]:this.selectionStart?!this.selectionEnd||this.areSelectionValuesReversed()?(e=this.selectionStart[0]+this.selectionStartLength)>this._bufferService.cols?e%this._bufferService.cols==0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]:this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]?(e=this.selectionStart[0]+this.selectionStartLength)>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]:this.selectionEnd:void 0;var e},enumerable:!1,configurable:!0}),e.prototype.areSelectionValuesReversed=function(){var e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])},e.prototype.onTrim=function(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):(this.selectionStart&&this.selectionStart[1]<0&&(this.selectionStart[1]=0),!1)},e}();t.SelectionModel=r},428:function(e,t,r){var i=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},n=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;var o=r(2585),s=r(8460),a=function(){function e(e,t,r){this._optionsService=r,this.width=0,this.height=0,this._onCharSizeChange=new s.EventEmitter,this._measureStrategy=new c(e,t,this._optionsService)}return Object.defineProperty(e.prototype,"hasValidSize",{get:function(){return this.width>0&&this.height>0},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onCharSizeChange",{get:function(){return this._onCharSizeChange.event},enumerable:!1,configurable:!0}),e.prototype.measure=function(){var e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())},i([n(2,o.IOptionsService)],e)}();t.CharSizeService=a;var c=function(){function e(e,t,r){this._document=e,this._parentElement=t,this._optionsService=r,this._result={width:0,height:0},this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W",this._measureElement.setAttribute("aria-hidden","true"),this._parentElement.appendChild(this._measureElement)}return e.prototype.measure=function(){this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=this._optionsService.rawOptions.fontSize+"px";var e=this._measureElement.getBoundingClientRect();return 0!==e.width&&0!==e.height&&(this._result.width=e.width,this._result.height=Math.ceil(e.height)),this._result},e}()},4269:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)}),o=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},s=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;var a=r(3734),c=r(643),l=r(511),h=r(2585),u=function(e){function t(t,r,i){var n=e.call(this)||this;return n.content=0,n.combinedData="",n.fg=t.fg,n.bg=t.bg,n.combinedData=r,n._width=i,n}return n(t,e),t.prototype.isCombined=function(){return 2097152},t.prototype.getWidth=function(){return this._width},t.prototype.getChars=function(){return this.combinedData},t.prototype.getCode=function(){return 2097151},t.prototype.setFromCharData=function(e){throw new Error("not implemented")},t.prototype.getAsCharData=function(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]},t}(a.AttributeData);t.JoinedCellData=u;var f=function(){function e(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new l.CellData}return e.prototype.register=function(e){var t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id},e.prototype.deregister=function(e){for(var t=0;t1)for(var u=this._getJoinedRanges(i,s,o,t,n),f=0;f1)for(u=this._getJoinedRanges(i,s,o,t,n),f=0;f{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreBrowserService=void 0;var r=function(){function e(e){this._textarea=e}return Object.defineProperty(e.prototype,"isFocused",{get:function(){return(this._textarea.getRootNode?this._textarea.getRootNode():document).activeElement===this._textarea&&document.hasFocus()},enumerable:!1,configurable:!0}),e}();t.CoreBrowserService=r},8934:function(e,t,r){var i=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},n=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseService=void 0;var o=r(4725),s=r(9806),a=function(){function e(e,t){this._renderService=e,this._charSizeService=t}return e.prototype.getCoords=function(e,t,r,i,n){return(0,s.getCoords)(window,e,t,r,i,this._charSizeService.hasValidSize,this._renderService.dimensions.actualCellWidth,this._renderService.dimensions.actualCellHeight,n)},e.prototype.getRawByteCoords=function(e,t,r,i){var n=this.getCoords(e,t,r,i);return(0,s.getRawByteCoords)(n)},i([n(0,o.IRenderService),n(1,o.ICharSizeService)],e)}();t.MouseService=a},3230:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)}),o=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},s=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;var a=r(6193),c=r(8460),l=r(844),h=r(5596),u=r(3656),f=r(2585),_=r(4725),d=function(e){function t(t,r,i,n,o,s,l){var f=e.call(this)||this;if(f._renderer=t,f._rowCount=r,f._charSizeService=o,f._isPaused=!1,f._needsFullRefresh=!1,f._isNextRenderRedrawOnly=!0,f._needsSelectionRefresh=!1,f._canvasWidth=0,f._canvasHeight=0,f._selectionState={start:void 0,end:void 0,columnSelectMode:!1},f._onDimensionsChange=new c.EventEmitter,f._onRenderedViewportChange=new c.EventEmitter,f._onRender=new c.EventEmitter,f._onRefreshRequest=new c.EventEmitter,f.register({dispose:function(){return f._renderer.dispose()}}),f._renderDebouncer=new a.RenderDebouncer((function(e,t){return f._renderRows(e,t)})),f.register(f._renderDebouncer),f._screenDprMonitor=new h.ScreenDprMonitor,f._screenDprMonitor.setListener((function(){return f.onDevicePixelRatioChange()})),f.register(f._screenDprMonitor),f.register(l.onResize((function(){return f._fullRefresh()}))),f.register(l.buffers.onBufferActivate((function(){var e;return null===(e=f._renderer)||void 0===e?void 0:e.clear()}))),f.register(n.onOptionChange((function(){return f._handleOptionsChanged()}))),f.register(f._charSizeService.onCharSizeChange((function(){return f.onCharSizeChanged()}))),f.register(s.onDecorationRegistered((function(){return f._fullRefresh()}))),f.register(s.onDecorationRemoved((function(){return f._fullRefresh()}))),f._renderer.onRequestRedraw((function(e){return f.refreshRows(e.start,e.end,!0)})),f.register((0,u.addDisposableDomListener)(window,"resize",(function(){return f.onDevicePixelRatioChange()}))),"IntersectionObserver"in window){var _=new IntersectionObserver((function(e){return f._onIntersectionChange(e[e.length-1])}),{threshold:0});_.observe(i),f.register({dispose:function(){return _.disconnect()}})}return f}return n(t,e),Object.defineProperty(t.prototype,"onDimensionsChange",{get:function(){return this._onDimensionsChange.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onRenderedViewportChange",{get:function(){return this._onRenderedViewportChange.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onRender",{get:function(){return this._onRender.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onRefreshRequest",{get:function(){return this._onRefreshRequest.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"dimensions",{get:function(){return this._renderer.dimensions},enumerable:!1,configurable:!0}),t.prototype._onIntersectionChange=function(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)},t.prototype.refreshRows=function(e,t,r){void 0===r&&(r=!1),this._isPaused?this._needsFullRefresh=!0:(r||(this._isNextRenderRedrawOnly=!1),this._renderDebouncer.refresh(e,t,this._rowCount))},t.prototype._renderRows=function(e,t){this._renderer.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.onSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0},t.prototype.resize=function(e,t){this._rowCount=t,this._fireOnCanvasResize()},t.prototype._handleOptionsChanged=function(){this._renderer.onOptionsChanged(),this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize()},t.prototype._fireOnCanvasResize=function(){this._renderer.dimensions.canvasWidth===this._canvasWidth&&this._renderer.dimensions.canvasHeight===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.dimensions)},t.prototype.dispose=function(){e.prototype.dispose.call(this)},t.prototype.setRenderer=function(e){var t=this;this._renderer.dispose(),this._renderer=e,this._renderer.onRequestRedraw((function(e){return t.refreshRows(e.start,e.end,!0)})),this._needsSelectionRefresh=!0,this._fullRefresh()},t.prototype.addRefreshCallback=function(e){return this._renderDebouncer.addRefreshCallback(e)},t.prototype._fullRefresh=function(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)},t.prototype.clearTextureAtlas=function(){var e,t;null===(t=null===(e=this._renderer)||void 0===e?void 0:e.clearTextureAtlas)||void 0===t||t.call(e),this._fullRefresh()},t.prototype.setColors=function(e){this._renderer.setColors(e),this._fullRefresh()},t.prototype.onDevicePixelRatioChange=function(){this._charSizeService.measure(),this._renderer.onDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1)},t.prototype.onResize=function(e,t){this._renderer.onResize(e,t),this._fullRefresh()},t.prototype.onCharSizeChanged=function(){this._renderer.onCharSizeChanged()},t.prototype.onBlur=function(){this._renderer.onBlur()},t.prototype.onFocus=function(){this._renderer.onFocus()},t.prototype.onSelectionChanged=function(e,t,r){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=r,this._renderer.onSelectionChanged(e,t,r)},t.prototype.onCursorMove=function(){this._renderer.onCursorMove()},t.prototype.clear=function(){this._renderer.clear()},o([s(3,f.IOptionsService),s(4,_.ICharSizeService),s(5,f.IDecorationService),s(6,f.IBufferService)],t)}(l.Disposable);t.RenderService=d},9312:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)}),o=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},s=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionService=void 0;var a=r(6114),c=r(456),l=r(511),h=r(8460),u=r(4725),f=r(2585),_=r(9806),d=r(9504),p=r(844),v=r(4841),y=String.fromCharCode(160),g=new RegExp(y,"g"),m=function(e){function t(t,r,i,n,o,s,a,u){var f=e.call(this)||this;return f._element=t,f._screenElement=r,f._linkifier=i,f._bufferService=n,f._coreService=o,f._mouseService=s,f._optionsService=a,f._renderService=u,f._dragScrollAmount=0,f._enabled=!0,f._workCell=new l.CellData,f._mouseDownTimeStamp=0,f._oldHasSelection=!1,f._oldSelectionStart=void 0,f._oldSelectionEnd=void 0,f._onLinuxMouseSelection=f.register(new h.EventEmitter),f._onRedrawRequest=f.register(new h.EventEmitter),f._onSelectionChange=f.register(new h.EventEmitter),f._onRequestScrollLines=f.register(new h.EventEmitter),f._mouseMoveListener=function(e){return f._onMouseMove(e)},f._mouseUpListener=function(e){return f._onMouseUp(e)},f._coreService.onUserInput((function(){f.hasSelection&&f.clearSelection()})),f._trimListener=f._bufferService.buffer.lines.onTrim((function(e){return f._onTrim(e)})),f.register(f._bufferService.buffers.onBufferActivate((function(e){return f._onBufferActivate(e)}))),f.enable(),f._model=new c.SelectionModel(f._bufferService),f._activeSelectionMode=0,f}return n(t,e),Object.defineProperty(t.prototype,"onLinuxMouseSelection",{get:function(){return this._onLinuxMouseSelection.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onRequestRedraw",{get:function(){return this._onRedrawRequest.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onSelectionChange",{get:function(){return this._onSelectionChange.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onRequestScrollLines",{get:function(){return this._onRequestScrollLines.event},enumerable:!1,configurable:!0}),t.prototype.dispose=function(){this._removeMouseDownListeners()},t.prototype.reset=function(){this.clearSelection()},t.prototype.disable=function(){this.clearSelection(),this._enabled=!1},t.prototype.enable=function(){this._enabled=!0},Object.defineProperty(t.prototype,"selectionStart",{get:function(){return this._model.finalSelectionStart},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"selectionEnd",{get:function(){return this._model.finalSelectionEnd},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"hasSelection",{get:function(){var e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"selectionText",{get:function(){var e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";var r=this._bufferService.buffer,i=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";for(var n=e[0]t[1]&&e[1]=t[0]&&e[0]=t[0]},t.prototype._selectWordAtCursor=function(e,t){var r,i,n=null===(i=null===(r=this._linkifier.currentLink)||void 0===r?void 0:r.link)||void 0===i?void 0:i.range;if(n)return this._model.selectionStart=[n.start.x-1,n.start.y-1],this._model.selectionStartLength=(0,v.getRangeLength)(n,this._bufferService.cols),this._model.selectionEnd=void 0,!0;var o=this._getMouseBufferCoords(e);return!!o&&(this._selectWordAt(o,t),this._model.selectionEnd=void 0,!0)},t.prototype.selectAll=function(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()},t.prototype.selectLines=function(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()},t.prototype._onTrim=function(e){this._model.onTrim(e)&&this.refresh()},t.prototype._getMouseBufferCoords=function(e){var t=this._mouseService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t},t.prototype._getMouseEventScrollAmount=function(e){var t=(0,_.getCoordsRelativeToElement)(window,e,this._screenElement)[1],r=this._renderService.dimensions.canvasHeight;return t>=0&&t<=r?0:(t>r&&(t-=r),t=Math.min(Math.max(t,-50),50),(t/=50)/Math.abs(t)+Math.round(14*t))},t.prototype.shouldForceSelection=function(e){return a.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey},t.prototype.onMouseDown=function(e){if(this._mouseDownTimeStamp=e.timeStamp,(2!==e.button||!this.hasSelection)&&0===e.button){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._onIncrementalClick(e):1===e.detail?this._onSingleClick(e):2===e.detail?this._onDoubleClick(e):3===e.detail&&this._onTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}},t.prototype._addMouseDownListeners=function(){var e=this;this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=window.setInterval((function(){return e._dragScroll()}),50)},t.prototype._removeMouseDownListeners=function(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0},t.prototype._onIncrementalClick=function(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))},t.prototype._onSingleClick=function(e){if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),this._model.selectionStart){this._model.selectionEnd=void 0;var t=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);t&&t.length!==this._model.selectionStart[0]&&0===t.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}},t.prototype._onDoubleClick=function(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)},t.prototype._onTripleClick=function(e){var t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))},t.prototype.shouldColumnSelect=function(e){return e.altKey&&!(a.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)},t.prototype._onMouseMove=function(e){if(e.stopImmediatePropagation(),this._model.selectionStart){var t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),this._model.selectionEnd){2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));var r=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}},t.prototype._onMouseUp=function(e){var t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.getOption("altClickMovesCursor")){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){var r=this._mouseService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(r&&void 0!==r[0]&&void 0!==r[1]){var i=(0,d.moveToCellSequence)(r[0]-1,r[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(i,!0)}}}else this._fireEventIfSelectionChanged()},t.prototype._fireEventIfSelectionChanged=function(){var e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,r=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);r?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,r)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,r)},t.prototype._fireOnSelectionChange=function(e,t,r){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=r,this._onSelectionChange.fire()},t.prototype._onBufferActivate=function(e){var t=this;this.clearSelection(),this._trimListener.dispose(),this._trimListener=e.activeBuffer.lines.onTrim((function(e){return t._onTrim(e)}))},t.prototype._convertViewportColToCharacterIndex=function(e,t){for(var r=t[0],i=0;t[0]>=i;i++){var n=e.loadCell(i,this._workCell).getChars().length;0===this._workCell.getWidth()?r--:n>1&&t[0]!==i&&(r+=n-1)}return r},t.prototype.setSelection=function(e,t,r){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=r,this.refresh(),this._fireEventIfSelectionChanged()},t.prototype.rightClickSelect=function(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())},t.prototype._getWordAt=function(e,t,r,i){if(void 0===r&&(r=!0),void 0===i&&(i=!0),!(e[0]>=this._bufferService.cols)){var n=this._bufferService.buffer,o=n.lines.get(e[1]);if(o){var s=n.translateBufferLineToString(e[1],!1),a=this._convertViewportColToCharacterIndex(o,e),c=a,l=e[0]-a,h=0,u=0,f=0,_=0;if(" "===s.charAt(a)){for(;a>0&&" "===s.charAt(a-1);)a--;for(;c1&&(_+=v-1,c+=v-1);d>0&&a>0&&!this._isCharWordSeparator(o.loadCell(d-1,this._workCell));){o.loadCell(d-1,this._workCell);var y=this._workCell.getChars().length;0===this._workCell.getWidth()?(h++,d--):y>1&&(f+=y-1,a-=y-1),a--,d--}for(;p1&&(_+=g-1,c+=g-1),c++,p++}}c++;var m=a+l-h+f,b=Math.min(this._bufferService.cols,c-a+h+u-f-_);if(t||""!==s.slice(a,c).trim()){if(r&&0===m&&32!==o.getCodePoint(0)){var S=n.lines.get(e[1]-1);if(S&&o.isWrapped&&32!==S.getCodePoint(this._bufferService.cols-1)){var C=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(C){var w=this._bufferService.cols-C.start;m-=w,b+=w}}}if(i&&m+b===this._bufferService.cols&&32!==o.getCodePoint(this._bufferService.cols-1)){var L=n.lines.get(e[1]+1);if((null==L?void 0:L.isWrapped)&&32!==L.getCodePoint(0)){var E=this._getWordAt([0,e[1]+1],!1,!1,!0);E&&(b+=E.length)}}return{start:m,length:b}}}}},t.prototype._selectWordAt=function(e,t){var r=this._getWordAt(e,t);if(r){for(;r.start<0;)r.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[r.start,e[1]],this._model.selectionStartLength=r.length}},t.prototype._selectToWordAt=function(e){var t=this._getWordAt(e,!0);if(t){for(var r=e[1];t.start<0;)t.start+=this._bufferService.cols,r--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,r++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,r]}},t.prototype._isCharWordSeparator=function(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0},t.prototype._selectLineAt=function(e){var t=this._bufferService.buffer.getWrappedRangeForLine(e),r={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,v.getRangeLength)(r,this._bufferService.cols)},o([s(3,f.IBufferService),s(4,f.ICoreService),s(5,u.IMouseService),s(6,f.IOptionsService),s(7,u.IRenderService)],t)}(p.Disposable);t.SelectionService=m},4725:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ICharacterJoinerService=t.ISoundService=t.ISelectionService=t.IRenderService=t.IMouseService=t.ICoreBrowserService=t.ICharSizeService=void 0;var i=r(8343);t.ICharSizeService=(0,i.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,i.createDecorator)("CoreBrowserService"),t.IMouseService=(0,i.createDecorator)("MouseService"),t.IRenderService=(0,i.createDecorator)("RenderService"),t.ISelectionService=(0,i.createDecorator)("SelectionService"),t.ISoundService=(0,i.createDecorator)("SoundService"),t.ICharacterJoinerService=(0,i.createDecorator)("CharacterJoinerService")},357:function(e,t,r){var i=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},n=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.SoundService=void 0;var o=r(2585),s=function(){function e(e){this._optionsService=e}return Object.defineProperty(e,"audioContext",{get:function(){if(!e._audioContext){var t=window.AudioContext||window.webkitAudioContext;if(!t)return console.warn("Web Audio API is not supported by this browser. Consider upgrading to the latest version"),null;e._audioContext=new t}return e._audioContext},enumerable:!1,configurable:!0}),e.prototype.playBellSound=function(){var t=e.audioContext;if(t){var r=t.createBufferSource();t.decodeAudioData(this._base64ToArrayBuffer(this._removeMimeType(this._optionsService.rawOptions.bellSound)),(function(e){r.buffer=e,r.connect(t.destination),r.start(0)}))}},e.prototype._base64ToArrayBuffer=function(e){for(var t=window.atob(e),r=t.length,i=new Uint8Array(r),n=0;n{Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;var i=r(8460),n=function(){function e(e){this._maxLength=e,this.onDeleteEmitter=new i.EventEmitter,this.onInsertEmitter=new i.EventEmitter,this.onTrimEmitter=new i.EventEmitter,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}return Object.defineProperty(e.prototype,"onDelete",{get:function(){return this.onDeleteEmitter.event},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onInsert",{get:function(){return this.onInsertEmitter.event},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onTrim",{get:function(){return this.onTrimEmitter.event},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"maxLength",{get:function(){return this._maxLength},set:function(e){if(this._maxLength!==e){for(var t=new Array(e),r=0;rthis._length)for(var t=this._length;t=e;n--)this._array[this._getCyclicIndex(n+r.length)]=this._array[this._getCyclicIndex(n)];for(n=0;nthis._maxLength){var o=this._length+r.length-this._maxLength;this._startIndex+=o,this._length=this._maxLength,this.onTrimEmitter.fire(o)}else this._length+=r.length},e.prototype.trimStart=function(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)},e.prototype.shiftElements=function(e,t,r){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+r<0)throw new Error("Cannot shift elements in list beyond index 0");if(r>0){for(var i=t-1;i>=0;i--)this.set(e+i+r,this.get(e+i));var n=e+t+r-this._length;if(n>0)for(this._length+=n;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(i=0;i{Object.defineProperty(t,"__esModule",{value:!0}),t.clone=void 0,t.clone=function e(t,r){if(void 0===r&&(r=5),"object"!=typeof t)return t;var i=Array.isArray(t)?[]:{};for(var n in t)i[n]=r<=1?t[n]:t[n]&&e(t[n],r-1);return i}},8055:function(e,t){var r,i,n,o,s=this&&this.__read||function(e,t){var r="function"==typeof Symbol&&e[Symbol.iterator];if(!r)return e;var i,n,o=r.call(e),s=[];try{for(;(void 0===t||t-- >0)&&!(i=o.next()).done;)s.push(i.value)}catch(e){n={error:e}}finally{try{i&&!i.done&&(r=o.return)&&r.call(o)}finally{if(n)throw n.error}}return s};function a(e){var t=e.toString(16);return t.length<2?"0"+t:t}function c(e,t){return e>>0}}(r=t.channels||(t.channels={})),(i=t.color||(t.color={})).blend=function(e,t){var i=(255&t.rgba)/255;if(1===i)return{css:t.css,rgba:t.rgba};var n=t.rgba>>24&255,o=t.rgba>>16&255,s=t.rgba>>8&255,a=e.rgba>>24&255,c=e.rgba>>16&255,l=e.rgba>>8&255,h=a+Math.round((n-a)*i),u=c+Math.round((o-c)*i),f=l+Math.round((s-l)*i);return{css:r.toCss(h,u,f),rgba:r.toRgba(h,u,f)}},i.isOpaque=function(e){return 255==(255&e.rgba)},i.ensureContrastRatio=function(e,t,r){var i=o.ensureContrastRatio(e.rgba,t.rgba,r);if(i)return o.toColor(i>>24&255,i>>16&255,i>>8&255)},i.opaque=function(e){var t=(255|e.rgba)>>>0,i=s(o.toChannels(t),3),n=i[0],a=i[1],c=i[2];return{css:r.toCss(n,a,c),rgba:t}},i.opacity=function(e,t){var i=Math.round(255*t),n=s(o.toChannels(e.rgba),3),a=n[0],c=n[1],l=n[2];return{css:r.toCss(a,c,l,i),rgba:r.toRgba(a,c,l,i)}},i.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]},(t.css||(t.css={})).toColor=function(e){if(e.match(/#[0-9a-f]{3,8}/i))switch(e.length){case 4:var t=parseInt(e.slice(1,2).repeat(2),16),r=parseInt(e.slice(2,3).repeat(2),16),i=parseInt(e.slice(3,4).repeat(2),16);return o.toColor(t,r,i);case 5:t=parseInt(e.slice(1,2).repeat(2),16),r=parseInt(e.slice(2,3).repeat(2),16),i=parseInt(e.slice(3,4).repeat(2),16);var n=parseInt(e.slice(4,5).repeat(2),16);return o.toColor(t,r,i,n);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}var s=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(s)return t=parseInt(s[1]),r=parseInt(s[2]),i=parseInt(s[3]),n=Math.round(255*(void 0===s[5]?1:parseFloat(s[5]))),o.toColor(t,r,i,n);throw new Error("css.toColor: Unsupported css format")},function(e){function t(e,t,r){var i=e/255,n=t/255,o=r/255;return.2126*(i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4))+.7152*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))+.0722*(o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(n=t.rgb||(t.rgb={})),function(e){function t(e,t,r){for(var i=e>>24&255,o=e>>16&255,s=e>>8&255,a=t>>24&255,l=t>>16&255,h=t>>8&255,u=c(n.relativeLuminance2(a,l,h),n.relativeLuminance2(i,o,s));u0||l>0||h>0);)a-=Math.max(0,Math.ceil(.1*a)),l-=Math.max(0,Math.ceil(.1*l)),h-=Math.max(0,Math.ceil(.1*h)),u=c(n.relativeLuminance2(a,l,h),n.relativeLuminance2(i,o,s));return(a<<24|l<<16|h<<8|255)>>>0}function i(e,t,r){for(var i=e>>24&255,o=e>>16&255,s=e>>8&255,a=t>>24&255,l=t>>16&255,h=t>>8&255,u=c(n.relativeLuminance2(a,l,h),n.relativeLuminance2(i,o,s));u>>0}e.ensureContrastRatio=function(e,r,o){var s=n.relativeLuminance(e>>8),a=n.relativeLuminance(r>>8);if(c(s,a)>8));if(hc(s,n.relativeLuminance(u>>8))?l:u}return l}var f=i(e,r,o),_=c(s,n.relativeLuminance(f>>8));return _c(s,n.relativeLuminance(u>>8))?f:u):f}},e.reduceLuminance=t,e.increaseLuminance=i,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]},e.toColor=function(e,t,i,n){return{css:r.toCss(e,t,i,n),rgba:r.toRgba(e,t,i,n)}}}(o=t.rgba||(t.rgba={})),t.toPaddedHex=a,t.contrastRatio=c},8969:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)}),o=this&&this.__values||function(e){var t="function"==typeof Symbol&&Symbol.iterator,r=t&&e[t],i=0;if(r)return r.call(e);if(e&&"number"==typeof e.length)return{next:function(){return e&&i>=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;var s=r(844),a=r(2585),c=r(4348),l=r(7866),h=r(744),u=r(7302),f=r(6975),_=r(8460),d=r(1753),p=r(3730),v=r(1480),y=r(7994),g=r(9282),m=r(5435),b=r(5981),S=!1,C=function(e){function t(t){var r=e.call(this)||this;return r._onBinary=new _.EventEmitter,r._onData=new _.EventEmitter,r._onLineFeed=new _.EventEmitter,r._onResize=new _.EventEmitter,r._onScroll=new _.EventEmitter,r._onWriteParsed=new _.EventEmitter,r._instantiationService=new c.InstantiationService,r.optionsService=new u.OptionsService(t),r._instantiationService.setService(a.IOptionsService,r.optionsService),r._bufferService=r.register(r._instantiationService.createInstance(h.BufferService)),r._instantiationService.setService(a.IBufferService,r._bufferService),r._logService=r._instantiationService.createInstance(l.LogService),r._instantiationService.setService(a.ILogService,r._logService),r.coreService=r.register(r._instantiationService.createInstance(f.CoreService,(function(){return r.scrollToBottom()}))),r._instantiationService.setService(a.ICoreService,r.coreService),r.coreMouseService=r._instantiationService.createInstance(d.CoreMouseService),r._instantiationService.setService(a.ICoreMouseService,r.coreMouseService),r._dirtyRowService=r._instantiationService.createInstance(p.DirtyRowService),r._instantiationService.setService(a.IDirtyRowService,r._dirtyRowService),r.unicodeService=r._instantiationService.createInstance(v.UnicodeService),r._instantiationService.setService(a.IUnicodeService,r.unicodeService),r._charsetService=r._instantiationService.createInstance(y.CharsetService),r._instantiationService.setService(a.ICharsetService,r._charsetService),r._inputHandler=new m.InputHandler(r._bufferService,r._charsetService,r.coreService,r._dirtyRowService,r._logService,r.optionsService,r.coreMouseService,r.unicodeService),r.register((0,_.forwardEvent)(r._inputHandler.onLineFeed,r._onLineFeed)),r.register(r._inputHandler),r.register((0,_.forwardEvent)(r._bufferService.onResize,r._onResize)),r.register((0,_.forwardEvent)(r.coreService.onData,r._onData)),r.register((0,_.forwardEvent)(r.coreService.onBinary,r._onBinary)),r.register(r.optionsService.onOptionChange((function(e){return r._updateOptions(e)}))),r.register(r._bufferService.onScroll((function(e){r._onScroll.fire({position:r._bufferService.buffer.ydisp,source:0}),r._dirtyRowService.markRangeDirty(r._bufferService.buffer.scrollTop,r._bufferService.buffer.scrollBottom)}))),r.register(r._inputHandler.onScroll((function(e){r._onScroll.fire({position:r._bufferService.buffer.ydisp,source:0}),r._dirtyRowService.markRangeDirty(r._bufferService.buffer.scrollTop,r._bufferService.buffer.scrollBottom)}))),r._writeBuffer=new b.WriteBuffer((function(e,t){return r._inputHandler.parse(e,t)})),r.register((0,_.forwardEvent)(r._writeBuffer.onWriteParsed,r._onWriteParsed)),r}return n(t,e),Object.defineProperty(t.prototype,"onBinary",{get:function(){return this._onBinary.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onData",{get:function(){return this._onData.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onLineFeed",{get:function(){return this._onLineFeed.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onResize",{get:function(){return this._onResize.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onWriteParsed",{get:function(){return this._onWriteParsed.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onScroll",{get:function(){var e=this;return this._onScrollApi||(this._onScrollApi=new _.EventEmitter,this.register(this._onScroll.event((function(t){var r;null===(r=e._onScrollApi)||void 0===r||r.fire(t.position)})))),this._onScrollApi.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"cols",{get:function(){return this._bufferService.cols},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"rows",{get:function(){return this._bufferService.rows},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"buffers",{get:function(){return this._bufferService.buffers},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"options",{get:function(){return this.optionsService.options},set:function(e){for(var t in e)this.optionsService.options[t]=e[t]},enumerable:!1,configurable:!0}),t.prototype.dispose=function(){var t;this._isDisposed||(e.prototype.dispose.call(this),null===(t=this._windowsMode)||void 0===t||t.dispose(),this._windowsMode=void 0)},t.prototype.write=function(e,t){this._writeBuffer.write(e,t)},t.prototype.writeSync=function(e,t){this._logService.logLevel<=a.LogLevelEnum.WARN&&!S&&(this._logService.warn("writeSync is unreliable and will be removed soon."),S=!0),this._writeBuffer.writeSync(e,t)},t.prototype.resize=function(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,h.MINIMUM_COLS),t=Math.max(t,h.MINIMUM_ROWS),this._bufferService.resize(e,t))},t.prototype.scroll=function(e,t){void 0===t&&(t=!1),this._bufferService.scroll(e,t)},t.prototype.scrollLines=function(e,t,r){this._bufferService.scrollLines(e,t,r)},t.prototype.scrollPages=function(e){this._bufferService.scrollPages(e)},t.prototype.scrollToTop=function(){this._bufferService.scrollToTop()},t.prototype.scrollToBottom=function(){this._bufferService.scrollToBottom()},t.prototype.scrollToLine=function(e){this._bufferService.scrollToLine(e)},t.prototype.registerEscHandler=function(e,t){return this._inputHandler.registerEscHandler(e,t)},t.prototype.registerDcsHandler=function(e,t){return this._inputHandler.registerDcsHandler(e,t)},t.prototype.registerCsiHandler=function(e,t){return this._inputHandler.registerCsiHandler(e,t)},t.prototype.registerOscHandler=function(e,t){return this._inputHandler.registerOscHandler(e,t)},t.prototype._setup=function(){this.optionsService.rawOptions.windowsMode&&this._enableWindowsMode()},t.prototype.reset=function(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.coreMouseService.reset()},t.prototype._updateOptions=function(e){var t;switch(e){case"scrollback":this.buffers.resize(this.cols,this.rows);break;case"windowsMode":this.optionsService.rawOptions.windowsMode?this._enableWindowsMode():(null===(t=this._windowsMode)||void 0===t||t.dispose(),this._windowsMode=void 0)}},t.prototype._enableWindowsMode=function(){var e=this;if(!this._windowsMode){var t=[];t.push(this.onLineFeed(g.updateWindowsModeWrappedState.bind(null,this._bufferService))),t.push(this.registerCsiHandler({final:"H"},(function(){return(0,g.updateWindowsModeWrappedState)(e._bufferService),!1}))),this._windowsMode={dispose:function(){var e,r;try{for(var i=o(t),n=i.next();!n.done;n=i.next())n.value.dispose()}catch(t){e={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(e)throw e.error}}}}}},t}(s.Disposable);t.CoreTerminal=C},8460:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.forwardEvent=t.EventEmitter=void 0;var r=function(){function e(){this._listeners=[],this._disposed=!1}return Object.defineProperty(e.prototype,"event",{get:function(){var e=this;return this._event||(this._event=function(t){return e._listeners.push(t),{dispose:function(){if(!e._disposed)for(var r=0;r24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(o=t.WindowsOptionsReportType||(t.WindowsOptionsReportType={}));var L=function(){function e(e,t,r,i){this._bufferService=e,this._coreService=t,this._logService=r,this._optionsService=i,this._data=new Uint32Array(0)}return e.prototype.hook=function(e){this._data=new Uint32Array(0)},e.prototype.put=function(e,t,r){this._data=(0,h.concat)(this._data,e.subarray(t,r))},e.prototype.unhook=function(e){if(!e)return this._data=new Uint32Array(0),!0;var t=(0,u.utf32ToString)(this._data);switch(this._data=new Uint32Array(0),t){case'"q':this._coreService.triggerDataEvent(s.C0.ESC+'P1$r0"q'+s.C0.ESC+"\\");break;case'"p':this._coreService.triggerDataEvent(s.C0.ESC+'P1$r61;1"p'+s.C0.ESC+"\\");break;case"r":var r=this._bufferService.buffer.scrollTop+1+";"+(this._bufferService.buffer.scrollBottom+1)+"r";this._coreService.triggerDataEvent(s.C0.ESC+"P1$r"+r+s.C0.ESC+"\\");break;case"m":this._coreService.triggerDataEvent(s.C0.ESC+"P1$r0m"+s.C0.ESC+"\\");break;case" q":var i={block:2,underline:4,bar:6}[this._optionsService.rawOptions.cursorStyle];i-=this._optionsService.rawOptions.cursorBlink?1:0,this._coreService.triggerDataEvent(s.C0.ESC+"P1$r"+i+" q"+s.C0.ESC+"\\");break;default:this._logService.debug("Unknown DCS $q %s",t),this._coreService.triggerDataEvent(s.C0.ESC+"P0$r"+s.C0.ESC+"\\")}return!0},e}(),E=function(e){function t(t,r,i,n,o,l,h,d,v){void 0===v&&(v=new c.EscapeSequenceParser);var y=e.call(this)||this;y._bufferService=t,y._charsetService=r,y._coreService=i,y._dirtyRowService=n,y._logService=o,y._optionsService=l,y._coreMouseService=h,y._unicodeService=d,y._parser=v,y._parseBuffer=new Uint32Array(4096),y._stringDecoder=new u.StringToUtf32,y._utf8Decoder=new u.Utf8ToUtf32,y._workCell=new p.CellData,y._windowTitle="",y._iconName="",y._windowTitleStack=[],y._iconNameStack=[],y._curAttrData=f.DEFAULT_ATTR_DATA.clone(),y._eraseAttrDataInternal=f.DEFAULT_ATTR_DATA.clone(),y._onRequestBell=new _.EventEmitter,y._onRequestRefreshRows=new _.EventEmitter,y._onRequestReset=new _.EventEmitter,y._onRequestSendFocus=new _.EventEmitter,y._onRequestSyncScrollBar=new _.EventEmitter,y._onRequestWindowsOptionsReport=new _.EventEmitter,y._onA11yChar=new _.EventEmitter,y._onA11yTab=new _.EventEmitter,y._onCursorMove=new _.EventEmitter,y._onLineFeed=new _.EventEmitter,y._onScroll=new _.EventEmitter,y._onTitleChange=new _.EventEmitter,y._onColor=new _.EventEmitter,y._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},y._specialColors=[256,257,258],y.register(y._parser),y._activeBuffer=y._bufferService.buffer,y.register(y._bufferService.buffers.onBufferActivate((function(e){return y._activeBuffer=e.activeBuffer}))),y._parser.setCsiHandlerFallback((function(e,t){y._logService.debug("Unknown CSI code: ",{identifier:y._parser.identToString(e),params:t.toArray()})})),y._parser.setEscHandlerFallback((function(e){y._logService.debug("Unknown ESC code: ",{identifier:y._parser.identToString(e)})})),y._parser.setExecuteHandlerFallback((function(e){y._logService.debug("Unknown EXECUTE code: ",{code:e})})),y._parser.setOscHandlerFallback((function(e,t,r){y._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:r})})),y._parser.setDcsHandlerFallback((function(e,t,r){"HOOK"===t&&(r=r.toArray()),y._logService.debug("Unknown DCS code: ",{identifier:y._parser.identToString(e),action:t,payload:r})})),y._parser.setPrintHandler((function(e,t,r){return y.print(e,t,r)})),y._parser.registerCsiHandler({final:"@"},(function(e){return y.insertChars(e)})),y._parser.registerCsiHandler({intermediates:" ",final:"@"},(function(e){return y.scrollLeft(e)})),y._parser.registerCsiHandler({final:"A"},(function(e){return y.cursorUp(e)})),y._parser.registerCsiHandler({intermediates:" ",final:"A"},(function(e){return y.scrollRight(e)})),y._parser.registerCsiHandler({final:"B"},(function(e){return y.cursorDown(e)})),y._parser.registerCsiHandler({final:"C"},(function(e){return y.cursorForward(e)})),y._parser.registerCsiHandler({final:"D"},(function(e){return y.cursorBackward(e)})),y._parser.registerCsiHandler({final:"E"},(function(e){return y.cursorNextLine(e)})),y._parser.registerCsiHandler({final:"F"},(function(e){return y.cursorPrecedingLine(e)})),y._parser.registerCsiHandler({final:"G"},(function(e){return y.cursorCharAbsolute(e)})),y._parser.registerCsiHandler({final:"H"},(function(e){return y.cursorPosition(e)})),y._parser.registerCsiHandler({final:"I"},(function(e){return y.cursorForwardTab(e)})),y._parser.registerCsiHandler({final:"J"},(function(e){return y.eraseInDisplay(e)})),y._parser.registerCsiHandler({prefix:"?",final:"J"},(function(e){return y.eraseInDisplay(e)})),y._parser.registerCsiHandler({final:"K"},(function(e){return y.eraseInLine(e)})),y._parser.registerCsiHandler({prefix:"?",final:"K"},(function(e){return y.eraseInLine(e)})),y._parser.registerCsiHandler({final:"L"},(function(e){return y.insertLines(e)})),y._parser.registerCsiHandler({final:"M"},(function(e){return y.deleteLines(e)})),y._parser.registerCsiHandler({final:"P"},(function(e){return y.deleteChars(e)})),y._parser.registerCsiHandler({final:"S"},(function(e){return y.scrollUp(e)})),y._parser.registerCsiHandler({final:"T"},(function(e){return y.scrollDown(e)})),y._parser.registerCsiHandler({final:"X"},(function(e){return y.eraseChars(e)})),y._parser.registerCsiHandler({final:"Z"},(function(e){return y.cursorBackwardTab(e)})),y._parser.registerCsiHandler({final:"`"},(function(e){return y.charPosAbsolute(e)})),y._parser.registerCsiHandler({final:"a"},(function(e){return y.hPositionRelative(e)})),y._parser.registerCsiHandler({final:"b"},(function(e){return y.repeatPrecedingCharacter(e)})),y._parser.registerCsiHandler({final:"c"},(function(e){return y.sendDeviceAttributesPrimary(e)})),y._parser.registerCsiHandler({prefix:">",final:"c"},(function(e){return y.sendDeviceAttributesSecondary(e)})),y._parser.registerCsiHandler({final:"d"},(function(e){return y.linePosAbsolute(e)})),y._parser.registerCsiHandler({final:"e"},(function(e){return y.vPositionRelative(e)})),y._parser.registerCsiHandler({final:"f"},(function(e){return y.hVPosition(e)})),y._parser.registerCsiHandler({final:"g"},(function(e){return y.tabClear(e)})),y._parser.registerCsiHandler({final:"h"},(function(e){return y.setMode(e)})),y._parser.registerCsiHandler({prefix:"?",final:"h"},(function(e){return y.setModePrivate(e)})),y._parser.registerCsiHandler({final:"l"},(function(e){return y.resetMode(e)})),y._parser.registerCsiHandler({prefix:"?",final:"l"},(function(e){return y.resetModePrivate(e)})),y._parser.registerCsiHandler({final:"m"},(function(e){return y.charAttributes(e)})),y._parser.registerCsiHandler({final:"n"},(function(e){return y.deviceStatus(e)})),y._parser.registerCsiHandler({prefix:"?",final:"n"},(function(e){return y.deviceStatusPrivate(e)})),y._parser.registerCsiHandler({intermediates:"!",final:"p"},(function(e){return y.softReset(e)})),y._parser.registerCsiHandler({intermediates:" ",final:"q"},(function(e){return y.setCursorStyle(e)})),y._parser.registerCsiHandler({final:"r"},(function(e){return y.setScrollRegion(e)})),y._parser.registerCsiHandler({final:"s"},(function(e){return y.saveCursor(e)})),y._parser.registerCsiHandler({final:"t"},(function(e){return y.windowOptions(e)})),y._parser.registerCsiHandler({final:"u"},(function(e){return y.restoreCursor(e)})),y._parser.registerCsiHandler({intermediates:"'",final:"}"},(function(e){return y.insertColumns(e)})),y._parser.registerCsiHandler({intermediates:"'",final:"~"},(function(e){return y.deleteColumns(e)})),y._parser.setExecuteHandler(s.C0.BEL,(function(){return y.bell()})),y._parser.setExecuteHandler(s.C0.LF,(function(){return y.lineFeed()})),y._parser.setExecuteHandler(s.C0.VT,(function(){return y.lineFeed()})),y._parser.setExecuteHandler(s.C0.FF,(function(){return y.lineFeed()})),y._parser.setExecuteHandler(s.C0.CR,(function(){return y.carriageReturn()})),y._parser.setExecuteHandler(s.C0.BS,(function(){return y.backspace()})),y._parser.setExecuteHandler(s.C0.HT,(function(){return y.tab()})),y._parser.setExecuteHandler(s.C0.SO,(function(){return y.shiftOut()})),y._parser.setExecuteHandler(s.C0.SI,(function(){return y.shiftIn()})),y._parser.setExecuteHandler(s.C1.IND,(function(){return y.index()})),y._parser.setExecuteHandler(s.C1.NEL,(function(){return y.nextLine()})),y._parser.setExecuteHandler(s.C1.HTS,(function(){return y.tabSet()})),y._parser.registerOscHandler(0,new g.OscHandler((function(e){return y.setTitle(e),y.setIconName(e),!0}))),y._parser.registerOscHandler(1,new g.OscHandler((function(e){return y.setIconName(e)}))),y._parser.registerOscHandler(2,new g.OscHandler((function(e){return y.setTitle(e)}))),y._parser.registerOscHandler(4,new g.OscHandler((function(e){return y.setOrReportIndexedColor(e)}))),y._parser.registerOscHandler(10,new g.OscHandler((function(e){return y.setOrReportFgColor(e)}))),y._parser.registerOscHandler(11,new g.OscHandler((function(e){return y.setOrReportBgColor(e)}))),y._parser.registerOscHandler(12,new g.OscHandler((function(e){return y.setOrReportCursorColor(e)}))),y._parser.registerOscHandler(104,new g.OscHandler((function(e){return y.restoreIndexedColor(e)}))),y._parser.registerOscHandler(110,new g.OscHandler((function(e){return y.restoreFgColor(e)}))),y._parser.registerOscHandler(111,new g.OscHandler((function(e){return y.restoreBgColor(e)}))),y._parser.registerOscHandler(112,new g.OscHandler((function(e){return y.restoreCursorColor(e)}))),y._parser.registerEscHandler({final:"7"},(function(){return y.saveCursor()})),y._parser.registerEscHandler({final:"8"},(function(){return y.restoreCursor()})),y._parser.registerEscHandler({final:"D"},(function(){return y.index()})),y._parser.registerEscHandler({final:"E"},(function(){return y.nextLine()})),y._parser.registerEscHandler({final:"H"},(function(){return y.tabSet()})),y._parser.registerEscHandler({final:"M"},(function(){return y.reverseIndex()})),y._parser.registerEscHandler({final:"="},(function(){return y.keypadApplicationMode()})),y._parser.registerEscHandler({final:">"},(function(){return y.keypadNumericMode()})),y._parser.registerEscHandler({final:"c"},(function(){return y.fullReset()})),y._parser.registerEscHandler({final:"n"},(function(){return y.setgLevel(2)})),y._parser.registerEscHandler({final:"o"},(function(){return y.setgLevel(3)})),y._parser.registerEscHandler({final:"|"},(function(){return y.setgLevel(3)})),y._parser.registerEscHandler({final:"}"},(function(){return y.setgLevel(2)})),y._parser.registerEscHandler({final:"~"},(function(){return y.setgLevel(1)})),y._parser.registerEscHandler({intermediates:"%",final:"@"},(function(){return y.selectDefaultCharset()})),y._parser.registerEscHandler({intermediates:"%",final:"G"},(function(){return y.selectDefaultCharset()}));var m=function(e){b._parser.registerEscHandler({intermediates:"(",final:e},(function(){return y.selectCharset("("+e)})),b._parser.registerEscHandler({intermediates:")",final:e},(function(){return y.selectCharset(")"+e)})),b._parser.registerEscHandler({intermediates:"*",final:e},(function(){return y.selectCharset("*"+e)})),b._parser.registerEscHandler({intermediates:"+",final:e},(function(){return y.selectCharset("+"+e)})),b._parser.registerEscHandler({intermediates:"-",final:e},(function(){return y.selectCharset("-"+e)})),b._parser.registerEscHandler({intermediates:".",final:e},(function(){return y.selectCharset("."+e)})),b._parser.registerEscHandler({intermediates:"/",final:e},(function(){return y.selectCharset("/"+e)}))},b=this;for(var S in a.CHARSETS)m(S);return y._parser.registerEscHandler({intermediates:"#",final:"8"},(function(){return y.screenAlignmentPattern()})),y._parser.setErrorHandler((function(e){return y._logService.error("Parsing error: ",e),e})),y._parser.registerDcsHandler({intermediates:"$",final:"q"},new L(y._bufferService,y._coreService,y._logService,y._optionsService)),y}return n(t,e),Object.defineProperty(t.prototype,"onRequestBell",{get:function(){return this._onRequestBell.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onRequestRefreshRows",{get:function(){return this._onRequestRefreshRows.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onRequestReset",{get:function(){return this._onRequestReset.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onRequestSendFocus",{get:function(){return this._onRequestSendFocus.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onRequestSyncScrollBar",{get:function(){return this._onRequestSyncScrollBar.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onRequestWindowsOptionsReport",{get:function(){return this._onRequestWindowsOptionsReport.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onA11yChar",{get:function(){return this._onA11yChar.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onA11yTab",{get:function(){return this._onA11yTab.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onCursorMove",{get:function(){return this._onCursorMove.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onLineFeed",{get:function(){return this._onLineFeed.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onScroll",{get:function(){return this._onScroll.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onTitleChange",{get:function(){return this._onTitleChange.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onColor",{get:function(){return this._onColor.event},enumerable:!1,configurable:!0}),t.prototype.dispose=function(){e.prototype.dispose.call(this)},t.prototype._preserveStack=function(e,t,r,i){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=r,this._parseStack.position=i},t.prototype._logSlowResolvingAsync=function(e){this._logService.logLevel<=y.LogLevelEnum.WARN&&Promise.race([e,new Promise((function(e,t){return setTimeout((function(){return t("#SLOW_TIMEOUT")}),5e3)}))]).catch((function(e){if("#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")}))},t.prototype.parse=function(e,t){var r,i=this._activeBuffer.x,n=this._activeBuffer.y,o=0,s=this._parseStack.paused;if(s){if(r=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(r),r;i=this._parseStack.cursorStartX,n=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>C&&(o=this._parseStack.position+C)}if(this._logService.logLevel<=y.LogLevelEnum.DEBUG&&this._logService.debug("parsing data"+("string"==typeof e?' "'+e+'"':' "'+Array.prototype.map.call(e,(function(e){return String.fromCharCode(e)})).join("")+'"'),"string"==typeof e?e.split("").map((function(e){return e.charCodeAt(0)})):e),this._parseBuffer.lengthC)for(var a=o;a0&&2===f.getWidth(this._activeBuffer.x-1)&&f.setCellFromCodePoint(this._activeBuffer.x-1,0,1,h.fg,h.bg,h.extended);for(var _=t;_=a)if(c){for(;this._activeBuffer.x=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),f=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)}else if(this._activeBuffer.x=a-1,2===n)continue;if(l&&(f.insertCells(this._activeBuffer.x,n,this._activeBuffer.getNullCell(h),h),2===f.getWidth(a-1)&&f.setCellFromCodePoint(a-1,d.NULL_CELL_CODE,d.NULL_CELL_WIDTH,h.fg,h.bg,h.extended)),f.setCellFromCodePoint(this._activeBuffer.x++,i,n,h.fg,h.bg,h.extended),n>0)for(;--n;)f.setCellFromCodePoint(this._activeBuffer.x++,0,0,h.fg,h.bg,h.extended)}else f.getWidth(this._activeBuffer.x-1)?f.addCodepointToCell(this._activeBuffer.x-1,i):f.addCodepointToCell(this._activeBuffer.x-2,i)}r-t>0&&(f.loadCell(this._activeBuffer.x-1,this._workCell),2===this._workCell.getWidth()||this._workCell.getCode()>65535?this._parser.precedingCodepoint=0:this._workCell.isCombined()?this._parser.precedingCodepoint=this._workCell.getChars().charCodeAt(0):this._parser.precedingCodepoint=this._workCell.content),this._activeBuffer.x0&&0===f.getWidth(this._activeBuffer.x)&&!f.hasContent(this._activeBuffer.x)&&f.setCellFromCodePoint(this._activeBuffer.x,0,1,h.fg,h.bg,h.extended),this._dirtyRowService.markDirty(this._activeBuffer.y)},t.prototype.registerCsiHandler=function(e,t){var r=this;return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,(function(e){return!w(e.params[0],r._optionsService.rawOptions.windowOptions)||t(e)}))},t.prototype.registerDcsHandler=function(e,t){return this._parser.registerDcsHandler(e,new m.DcsHandler(t))},t.prototype.registerEscHandler=function(e,t){return this._parser.registerEscHandler(e,t)},t.prototype.registerOscHandler=function(e,t){return this._parser.registerOscHandler(e,new g.OscHandler(t))},t.prototype.bell=function(){return this._onRequestBell.fire(),!0},t.prototype.lineFeed=function(){return this._dirtyRowService.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowService.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0},t.prototype.carriageReturn=function(){return this._activeBuffer.x=0,!0},t.prototype.backspace=function(){var e;if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&(null===(e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y))||void 0===e?void 0:e.isWrapped)){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;var t=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);t.hasWidth(this._activeBuffer.x)&&!t.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0},t.prototype.tab=function(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;var e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0},t.prototype.shiftOut=function(){return this._charsetService.setgLevel(1),!0},t.prototype.shiftIn=function(){return this._charsetService.setgLevel(0),!0},t.prototype._restrictCursor=function(e){void 0===e&&(e=this._bufferService.cols-1),this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowService.markDirty(this._activeBuffer.y)},t.prototype._setCursor=function(e,t){this._dirtyRowService.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowService.markDirty(this._activeBuffer.y)},t.prototype._moveCursor=function(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)},t.prototype.cursorUp=function(e){var t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0},t.prototype.cursorDown=function(e){var t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0},t.prototype.cursorForward=function(e){return this._moveCursor(e.params[0]||1,0),!0},t.prototype.cursorBackward=function(e){return this._moveCursor(-(e.params[0]||1),0),!0},t.prototype.cursorNextLine=function(e){return this.cursorDown(e),this._activeBuffer.x=0,!0},t.prototype.cursorPrecedingLine=function(e){return this.cursorUp(e),this._activeBuffer.x=0,!0},t.prototype.cursorCharAbsolute=function(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0},t.prototype.cursorPosition=function(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0},t.prototype.charPosAbsolute=function(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0},t.prototype.hPositionRelative=function(e){return this._moveCursor(e.params[0]||1,0),!0},t.prototype.linePosAbsolute=function(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0},t.prototype.vPositionRelative=function(e){return this._moveCursor(0,e.params[0]||1),!0},t.prototype.hVPosition=function(e){return this.cursorPosition(e),!0},t.prototype.tabClear=function(e){var t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0},t.prototype.cursorForwardTab=function(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;for(var t=e.params[0]||1;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0},t.prototype.cursorBackwardTab=function(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;for(var t=e.params[0]||1;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0},t.prototype._eraseInBufferLine=function(e,t,r,i){void 0===i&&(i=!1);var n=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);n.replaceCells(t,r,this._activeBuffer.getNullCell(this._eraseAttrData()),this._eraseAttrData()),i&&(n.isWrapped=!1)},t.prototype._resetBufferLine=function(e){var t=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);t.fill(this._activeBuffer.getNullCell(this._eraseAttrData())),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),t.isWrapped=!1},t.prototype.eraseInDisplay=function(e){var t;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(t=this._activeBuffer.y,this._dirtyRowService.markDirty(t),this._eraseInBufferLine(t++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x);t=this._bufferService.cols&&(this._activeBuffer.lines.get(t+1).isWrapped=!1);t--;)this._resetBufferLine(t);this._dirtyRowService.markDirty(0);break;case 2:for(t=this._bufferService.rows,this._dirtyRowService.markDirty(t-1);t--;)this._resetBufferLine(t);this._dirtyRowService.markDirty(0);break;case 3:var r=this._activeBuffer.lines.length-this._bufferService.rows;r>0&&(this._activeBuffer.lines.trimStart(r),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-r,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-r,0),this._onScroll.fire(0))}return!0},t.prototype.eraseInLine=function(e){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0)}return this._dirtyRowService.markDirty(this._activeBuffer.y),!0},t.prototype.insertLines=function(e){this._restrictCursor();var t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent(s.C0.ESC+"[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent(s.C0.ESC+"[?6c")),!0},t.prototype.sendDeviceAttributesSecondary=function(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent(s.C0.ESC+"[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent(s.C0.ESC+"[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent(s.C0.ESC+"[>83;40003;0c")),!0},t.prototype._is=function(e){return 0===(this._optionsService.rawOptions.termName+"").indexOf(e)},t.prototype.setMode=function(e){for(var t=0;t=2||2===i[1]&&o+n>=5)break;i[1]&&(n=1)}while(++o+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()},t.prototype.charAttributes=function(e){if(1===e.length&&0===e.params[0])return this._curAttrData.fg=f.DEFAULT_ATTR_DATA.fg,this._curAttrData.bg=f.DEFAULT_ATTR_DATA.bg,!0;for(var t,r=e.length,i=this._curAttrData,n=0;n=30&&t<=37?(i.fg&=-50331904,i.fg|=16777216|t-30):t>=40&&t<=47?(i.bg&=-50331904,i.bg|=16777216|t-40):t>=90&&t<=97?(i.fg&=-50331904,i.fg|=16777224|t-90):t>=100&&t<=107?(i.bg&=-50331904,i.bg|=16777224|t-100):0===t?(i.fg=f.DEFAULT_ATTR_DATA.fg,i.bg=f.DEFAULT_ATTR_DATA.bg):1===t?i.fg|=134217728:3===t?i.bg|=67108864:4===t?(i.fg|=268435456,this._processUnderline(e.hasSubParams(n)?e.getSubParams(n)[0]:1,i)):5===t?i.fg|=536870912:7===t?i.fg|=67108864:8===t?i.fg|=1073741824:9===t?i.fg|=2147483648:2===t?i.bg|=134217728:21===t?this._processUnderline(2,i):22===t?(i.fg&=-134217729,i.bg&=-134217729):23===t?i.bg&=-67108865:24===t?i.fg&=-268435457:25===t?i.fg&=-536870913:27===t?i.fg&=-67108865:28===t?i.fg&=-1073741825:29===t?i.fg&=2147483647:39===t?(i.fg&=-67108864,i.fg|=16777215&f.DEFAULT_ATTR_DATA.fg):49===t?(i.bg&=-67108864,i.bg|=16777215&f.DEFAULT_ATTR_DATA.bg):38===t||48===t||58===t?n+=this._extractColor(e,n,i):59===t?(i.extended=i.extended.clone(),i.extended.underlineColor=-1,i.updateExtended()):100===t?(i.fg&=-67108864,i.fg|=16777215&f.DEFAULT_ATTR_DATA.fg,i.bg&=-67108864,i.bg|=16777215&f.DEFAULT_ATTR_DATA.bg):this._logService.debug("Unknown SGR attribute: %d.",t);return!0},t.prototype.deviceStatus=function(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent(s.C0.ESC+"[0n");break;case 6:var t=this._activeBuffer.y+1,r=this._activeBuffer.x+1;this._coreService.triggerDataEvent(s.C0.ESC+"["+t+";"+r+"R")}return!0},t.prototype.deviceStatusPrivate=function(e){if(6===e.params[0]){var t=this._activeBuffer.y+1,r=this._activeBuffer.x+1;this._coreService.triggerDataEvent(s.C0.ESC+"[?"+t+";"+r+"R")}return!0},t.prototype.softReset=function(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=f.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0},t.prototype.setCursorStyle=function(e){var t=e.params[0]||1;switch(t){case 1:case 2:this._optionsService.options.cursorStyle="block";break;case 3:case 4:this._optionsService.options.cursorStyle="underline";break;case 5:case 6:this._optionsService.options.cursorStyle="bar"}var r=t%2==1;return this._optionsService.options.cursorBlink=r,!0},t.prototype.setScrollRegion=function(e){var t,r=e.params[0]||1;return(e.length<2||(t=e.params[1])>this._bufferService.rows||0===t)&&(t=this._bufferService.rows),t>r&&(this._activeBuffer.scrollTop=r-1,this._activeBuffer.scrollBottom=t-1,this._setCursor(0,0)),!0},t.prototype.windowOptions=function(e){if(!w(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;var t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(o.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(o.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(s.C0.ESC+"[8;"+this._bufferService.rows+";"+this._bufferService.cols+"t");break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0},t.prototype.saveCursor=function(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,!0},t.prototype.restoreCursor=function(e){return this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg,this._charsetService.charset=this._savedCharset,this._activeBuffer.savedCharset&&(this._charsetService.charset=this._activeBuffer.savedCharset),this._restrictCursor(),!0},t.prototype.setTitle=function(e){return this._windowTitle=e,this._onTitleChange.fire(e),!0},t.prototype.setIconName=function(e){return this._iconName=e,!0},t.prototype.setOrReportIndexedColor=function(e){for(var t=[],r=e.split(";");r.length>1;){var i=r.shift(),n=r.shift();if(/^\d+$/.exec(i)){var o=parseInt(i);if(0<=o&&o<256)if("?"===n)t.push({type:0,index:o});else{var s=(0,b.parseColor)(n);s&&t.push({type:1,index:o,color:s})}}}return t.length&&this._onColor.fire(t),!0},t.prototype._setOrReportSpecialColor=function(e,t){for(var r=e.split(";"),i=0;i=this._specialColors.length);++i,++t)if("?"===r[i])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{var n=(0,b.parseColor)(r[i]);n&&this._onColor.fire([{type:1,index:this._specialColors[t],color:n}])}return!0},t.prototype.setOrReportFgColor=function(e){return this._setOrReportSpecialColor(e,0)},t.prototype.setOrReportBgColor=function(e){return this._setOrReportSpecialColor(e,1)},t.prototype.setOrReportCursorColor=function(e){return this._setOrReportSpecialColor(e,2)},t.prototype.restoreIndexedColor=function(e){if(!e)return this._onColor.fire([{type:2}]),!0;for(var t=[],r=e.split(";"),i=0;i=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0},t.prototype.tabSet=function(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0},t.prototype.reverseIndex=function(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){var e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowService.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0},t.prototype.fullReset=function(){return this._parser.reset(),this._onRequestReset.fire(),!0},t.prototype.reset=function(){this._curAttrData=f.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=f.DEFAULT_ATTR_DATA.clone()},t.prototype._eraseAttrData=function(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal},t.prototype.setgLevel=function(e){return this._charsetService.setgLevel(e),!0},t.prototype.screenAlignmentPattern=function(){var e=new p.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(var t=0;t=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(t,"__esModule",{value:!0}),t.getDisposeArrayDisposable=t.disposeArray=t.Disposable=void 0;var i=function(){function e(){this._disposables=[],this._isDisposed=!1}return e.prototype.dispose=function(){var e,t;this._isDisposed=!0;try{for(var i=r(this._disposables),n=i.next();!n.done;n=i.next())n.value.dispose()}catch(t){e={error:t}}finally{try{n&&!n.done&&(t=i.return)&&t.call(i)}finally{if(e)throw e.error}}this._disposables.length=0},e.prototype.register=function(e){return this._disposables.push(e),e},e.prototype.unregister=function(e){var t=this._disposables.indexOf(e);-1!==t&&this._disposables.splice(t,1)},e}();function n(e){var t,i;try{for(var n=r(e),o=n.next();!o.done;o=n.next())o.value.dispose()}catch(e){t={error:e}}finally{try{o&&!o.done&&(i=n.return)&&i.call(n)}finally{if(t)throw t.error}}e.length=0}t.Disposable=i,t.disposeArray=n,t.getDisposeArrayDisposable=function(e){return{dispose:function(){return n(e)}}}},6114:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isLinux=t.isWindows=t.isIphone=t.isIpad=t.isMac=t.isSafari=t.isLegacyEdge=t.isFirefox=void 0;var r="undefined"==typeof navigator,i=r?"node":navigator.userAgent,n=r?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(n),t.isIpad="iPad"===n,t.isIphone="iPhone"===n,t.isWindows=["Windows","Win16","Win32","WinCE"].includes(n),t.isLinux=n.indexOf("Linux")>=0},6106:function(e,t){var r=this&&this.__generator||function(e,t){var r,i,n,o,s={label:0,sent:function(){if(1&n[0])throw n[1];return n[1]},trys:[],ops:[]};return o={next:a(0),throw:a(1),return:a(2)},"function"==typeof Symbol&&(o[Symbol.iterator]=function(){return this}),o;function a(o){return function(a){return function(o){if(r)throw new TypeError("Generator is already executing.");for(;s;)try{if(r=1,i&&(n=2&o[0]?i.return:o[0]?i.throw||((n=i.return)&&n.call(i),0):i.next)&&!(n=n.call(i,o[1])).done)return n;switch(i=0,n&&(o=[2&o[0],n.value]),o[0]){case 0:case 1:n=o;break;case 4:return s.label++,{value:o[1],done:!1};case 5:s.label++,i=o[1],o=[0];continue;case 7:o=s.ops.pop(),s.trys.pop();continue;default:if(!((n=(n=s.trys).length>0&&n[n.length-1])||6!==o[0]&&2!==o[0])){s=0;continue}if(3===o[0]&&(!n||o[1]>n[0]&&o[1]=this._array.length)return[2];if(this._getKey(this._array[t])!==e)return[2];r.label=1;case 1:return[4,this._array[t]];case 2:r.sent(),r.label=3;case 3:if(++te)return this._search(e,t,i-1);if(this._getKey(this._array[i])0&&this._getKey(this._array[i-1])===e;)i--;return i},e}();t.SortedList=i},8273:(e,t)=>{function r(e,t,r,i){if(void 0===r&&(r=0),void 0===i&&(i=e.length),r>=e.length)return e;r=(e.length+r)%e.length,i=i>=e.length?e.length:(e.length+i)%e.length;for(var n=r;n{Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=void 0;var i=r(643);t.updateWindowsModeWrappedState=function(e){var t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),r=null==t?void 0:t.get(e.cols-1),n=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);n&&r&&(n.isWrapped=r[i.CHAR_DATA_CODE_INDEX]!==i.NULL_CELL_CODE&&r[i.CHAR_DATA_CODE_INDEX]!==i.WHITESPACE_CELL_CODE)}},3734:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;var r=function(){function e(){this.fg=0,this.bg=0,this.extended=new i}return e.toColorRGB=function(e){return[e>>>16&255,e>>>8&255,255&e]},e.fromColorRGB=function(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]},e.prototype.clone=function(){var t=new e;return t.fg=this.fg,t.bg=this.bg,t.extended=this.extended.clone(),t},e.prototype.isInverse=function(){return 67108864&this.fg},e.prototype.isBold=function(){return 134217728&this.fg},e.prototype.isUnderline=function(){return 268435456&this.fg},e.prototype.isBlink=function(){return 536870912&this.fg},e.prototype.isInvisible=function(){return 1073741824&this.fg},e.prototype.isItalic=function(){return 67108864&this.bg},e.prototype.isDim=function(){return 134217728&this.bg},e.prototype.isStrikethrough=function(){return 2147483648&this.fg},e.prototype.getFgColorMode=function(){return 50331648&this.fg},e.prototype.getBgColorMode=function(){return 50331648&this.bg},e.prototype.isFgRGB=function(){return 50331648==(50331648&this.fg)},e.prototype.isBgRGB=function(){return 50331648==(50331648&this.bg)},e.prototype.isFgPalette=function(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)},e.prototype.isBgPalette=function(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)},e.prototype.isFgDefault=function(){return 0==(50331648&this.fg)},e.prototype.isBgDefault=function(){return 0==(50331648&this.bg)},e.prototype.isAttributeDefault=function(){return 0===this.fg&&0===this.bg},e.prototype.getFgColor=function(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}},e.prototype.getBgColor=function(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}},e.prototype.hasExtendedAttrs=function(){return 268435456&this.bg},e.prototype.updateExtended=function(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456},e.prototype.getUnderlineColor=function(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()},e.prototype.getUnderlineColorMode=function(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()},e.prototype.isUnderlineColorRGB=function(){return 268435456&this.bg&&~this.extended.underlineColor?50331648==(50331648&this.extended.underlineColor):this.isFgRGB()},e.prototype.isUnderlineColorPalette=function(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()},e.prototype.isUnderlineColorDefault=function(){return 268435456&this.bg&&~this.extended.underlineColor?0==(50331648&this.extended.underlineColor):this.isFgDefault()},e.prototype.getUnderlineStyle=function(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0},e}();t.AttributeData=r;var i=function(){function e(e,t){void 0===e&&(e=0),void 0===t&&(t=-1),this.underlineStyle=e,this.underlineColor=t}return e.prototype.clone=function(){return new e(this.underlineStyle,this.underlineColor)},e.prototype.isEmpty=function(){return 0===this.underlineStyle},e}();t.ExtendedAttrs=i},9092:function(e,t,r){var i=this&&this.__read||function(e,t){var r="function"==typeof Symbol&&e[Symbol.iterator];if(!r)return e;var i,n,o=r.call(e),s=[];try{for(;(void 0===t||t-- >0)&&!(i=o.next()).done;)s.push(i.value)}catch(e){n={error:e}}finally{try{i&&!i.done&&(r=o.return)&&r.call(o)}finally{if(n)throw n.error}}return s},n=this&&this.__spreadArray||function(e,t,r){if(r||2===arguments.length)for(var i,n=0,o=t.length;nthis._rows},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"isCursorInViewport",{get:function(){var e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:r},e.prototype.fillViewportRows=function(e){if(0===this.lines.length){void 0===e&&(e=s.DEFAULT_ATTR_DATA);for(var t=this._rows;t--;)this.lines.push(this.getBlankLine(e))}},e.prototype.clear=function(){this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new o.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()},e.prototype.resize=function(e,t){var r=this.getNullCell(s.DEFAULT_ATTR_DATA),i=this._getCorrectBufferLength(t);if(i>this.lines.maxLength&&(this.lines.maxLength=i),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+o+1?(this.ybase--,o++,this.ydisp>0&&this.ydisp--):this.lines.push(new s.BufferLine(e,r)));else for(a=this._rows;a>t;a--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(i0&&(this.lines.trimStart(c),this.ybase=Math.max(this.ybase-c,0),this.ydisp=Math.max(this.ydisp-c,0),this.savedY=Math.max(this.savedY-c,0)),this.lines.maxLength=i}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),o&&(this.y+=o),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(n=0;nthis._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))},e.prototype._reflowLarger=function(e,t){var r=(0,l.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(s.DEFAULT_ATTR_DATA));if(r.length>0){var i=(0,l.reflowLargerCreateNewLayout)(this.lines,r);(0,l.reflowLargerApplyNewLayout)(this.lines,i.layout),this._reflowLargerAdjustViewport(e,t,i.countRemoved)}},e.prototype._reflowLargerAdjustViewport=function(e,t,r){for(var i=this.getNullCell(s.DEFAULT_ATTR_DATA),n=r;n-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;c--){var h=this.lines.get(c);if(!(!h||!h.isWrapped&&h.getTrimmedLength()<=e)){for(var u=[h];h.isWrapped&&c>0;)h=this.lines.get(--c),u.unshift(h);var f=this.ybase+this.y;if(!(f>=c&&f0&&(o.push({start:c+u.length+a,newLines:y}),a+=y.length),u.push.apply(u,n([],i(y),!1));var b=p.length-1,S=p[b];0===S&&(S=p[--b]);for(var C=u.length-v-1,w=d;C>=0;){var L=Math.min(w,S);if(void 0===u[b])break;if(u[b].copyCellsFrom(u[C],w-L,S-L,L,!0),0==(S-=L)&&(S=p[--b]),0==(w-=L)){C--;var E=Math.max(C,0);w=(0,l.getWrappedLineTrimmedLength)(u,E,this._cols)}}for(g=0;g0;)0===this.ybase?this.y0){var R=[],k=[];for(g=0;g=0;g--)if(D&&D.start>A+T){for(var B=D.newLines.length-1;B>=0;B--)this.lines.set(g--,D.newLines[B]);g++,R.push({index:A+1,amount:D.newLines.length}),T+=D.newLines.length,D=o[++O]}else this.lines.set(g,k[A--]);var P=0;for(g=R.length-1;g>=0;g--)R[g].index+=P,this.lines.onInsertEmitter.fire(R[g]),P+=R[g].amount;var I=Math.max(0,M+a-this.lines.maxLength);I>0&&this.lines.onTrimEmitter.fire(I)}},e.prototype.stringIndexToBufferIndex=function(e,t,r){for(void 0===r&&(r=!1);t;){var i=this.lines.get(e);if(!i)return[-1,-1];for(var n=r?i.getTrimmedLength():i.length,o=0;o0&&this.lines.get(t).isWrapped;)t--;for(;r+10;);return e>=this._cols?this._cols-1:e<0?0:e},e.prototype.nextStop=function(e){for(null==e&&(e=this.x);!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e},e.prototype.clearMarkers=function(e){this._isClearing=!0;for(var t=0;t=e.index&&(r.line+=e.amount)}))),r.register(this.lines.onDelete((function(e){r.line>=e.index&&r.linee.index&&(r.line-=e.amount)}))),r.register(r.onDispose((function(){return t._removeMarker(r)}))),r},e.prototype._removeMarker=function(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)},e.prototype.iterator=function(e,t,r,i,n){return new d(this,e,t,r,i,n)},e}();t.Buffer=_;var d=function(){function e(e,t,r,i,n,o){void 0===r&&(r=0),void 0===i&&(i=e.lines.length),void 0===n&&(n=0),void 0===o&&(o=0),this._buffer=e,this._trimRight=t,this._startIndex=r,this._endIndex=i,this._startOverscan=n,this._endOverscan=o,this._startIndex<0&&(this._startIndex=0),this._endIndex>this._buffer.lines.length&&(this._endIndex=this._buffer.lines.length),this._current=this._startIndex}return e.prototype.hasNext=function(){return this._currentthis._endIndex+this._endOverscan&&(e.last=this._endIndex+this._endOverscan),e.first=Math.max(e.first,0),e.last=Math.min(e.last,this._buffer.lines.length);for(var t="",r=e.first;r<=e.last;++r)t+=this._buffer.translateBufferLineToString(r,this._trimRight);return this._current=e.last+1,{range:e,content:t}},e}();t.BufferStringIterator=d},8437:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;var i=r(482),n=r(643),o=r(511),s=r(3734);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);var a=function(){function e(e,t,r){void 0===r&&(r=!1),this.isWrapped=r,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*e);for(var i=t||o.CellData.fromCharData([0,n.NULL_CELL_CHAR,n.NULL_CELL_WIDTH,n.NULL_CELL_CODE]),s=0;s>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):r]},e.prototype.set=function(e,t){this._data[3*e+1]=t[n.CHAR_DATA_ATTR_INDEX],t[n.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[n.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[n.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[n.CHAR_DATA_WIDTH_INDEX]<<22},e.prototype.getWidth=function(e){return this._data[3*e+0]>>22},e.prototype.hasWidth=function(e){return 12582912&this._data[3*e+0]},e.prototype.getFg=function(e){return this._data[3*e+1]},e.prototype.getBg=function(e){return this._data[3*e+2]},e.prototype.hasContent=function(e){return 4194303&this._data[3*e+0]},e.prototype.getCodePoint=function(e){var t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t},e.prototype.isCombined=function(e){return 2097152&this._data[3*e+0]},e.prototype.getString=function(e){var t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,i.stringFromCodePoint)(2097151&t):""},e.prototype.loadCell=function(e,t){var r=3*e;return t.content=this._data[r+0],t.fg=this._data[r+1],t.bg=this._data[r+2],2097152&t.content&&(t.combinedData=this._combined[e]),268435456&t.bg&&(t.extended=this._extendedAttrs[e]),t},e.prototype.setCell=function(e,t){2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg},e.prototype.setCellFromCodePoint=function(e,t,r,i,n,o){268435456&n&&(this._extendedAttrs[e]=o),this._data[3*e+0]=t|r<<22,this._data[3*e+1]=i,this._data[3*e+2]=n},e.prototype.addCodepointToCell=function(e,t){var r=this._data[3*e+0];2097152&r?this._combined[e]+=(0,i.stringFromCodePoint)(t):(2097151&r?(this._combined[e]=(0,i.stringFromCodePoint)(2097151&r)+(0,i.stringFromCodePoint)(t),r&=-2097152,r|=2097152):r=t|1<<22,this._data[3*e+0]=r)},e.prototype.insertCells=function(e,t,r,i){if((e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodePoint(e-1,0,1,(null==i?void 0:i.fg)||0,(null==i?void 0:i.bg)||0,(null==i?void 0:i.extended)||new s.ExtendedAttrs),t=0;--a)this.setCell(e+t+a,this.loadCell(e+a,n));for(a=0;athis.length){var r=new Uint32Array(3*e);this.length&&(3*e=e&&delete this._combined[o]}}else this._data=new Uint32Array(0),this._combined={};this.length=e}},e.prototype.fill=function(e){this._combined={},this._extendedAttrs={};for(var t=0;t=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0},e.prototype.copyCellsFrom=function(e,t,r,i,n){var o=e._data;if(n)for(var s=i-1;s>=0;s--)for(var a=0;a<3;a++)this._data[3*(r+s)+a]=o[3*(t+s)+a];else for(s=0;s=t&&(this._combined[l-t+r]=e._combined[l])}},e.prototype.translateToString=function(e,t,r){void 0===e&&(e=!1),void 0===t&&(t=0),void 0===r&&(r=this.length),e&&(r=Math.min(r,this.getTrimmedLength()));for(var o="";t>22||1}return o},e}();t.BufferLine=a},4841:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=void 0,t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error("Buffer range end ("+e.end.x+", "+e.end.y+") cannot be before start ("+e.start.x+", "+e.start.y+")");return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},4634:(e,t)=>{function r(e,t,r){if(t===e.length-1)return e[t].getTrimmedLength();var i=!e[t].hasContent(r-1)&&1===e[t].getWidth(r-1),n=2===e[t+1].getWidth(0);return i&&n?r-1:r}Object.defineProperty(t,"__esModule",{value:!0}),t.getWrappedLineTrimmedLength=t.reflowSmallerGetNewLineLengths=t.reflowLargerApplyNewLayout=t.reflowLargerCreateNewLayout=t.reflowLargerGetLinesToRemove=void 0,t.reflowLargerGetLinesToRemove=function(e,t,i,n,o){for(var s=[],a=0;a=a&&n0&&(b>u||0===h[b].getTrimmedLength());b--)m++;m>0&&(s.push(a+h.length-m),s.push(m)),a+=h.length-1}}}return s},t.reflowLargerCreateNewLayout=function(e,t){for(var r=[],i=0,n=t[i],o=0,s=0;sl&&(s-=l,a++);var h=2===e[a].getWidth(s-1);h&&s--;var u=h?i-1:i;n.push(u),c+=u}return n},t.getWrappedLineTrimmedLength=r},5295:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)});Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;var o=r(9092),s=r(8460),a=function(e){function t(t,r){var i=e.call(this)||this;return i._optionsService=t,i._bufferService=r,i._onBufferActivate=i.register(new s.EventEmitter),i.reset(),i}return n(t,e),Object.defineProperty(t.prototype,"onBufferActivate",{get:function(){return this._onBufferActivate.event},enumerable:!1,configurable:!0}),t.prototype.reset=function(){this._normal=new o.Buffer(!0,this._optionsService,this._bufferService),this._normal.fillViewportRows(),this._alt=new o.Buffer(!1,this._optionsService,this._bufferService),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()},Object.defineProperty(t.prototype,"alt",{get:function(){return this._alt},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"active",{get:function(){return this._activeBuffer},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"normal",{get:function(){return this._normal},enumerable:!1,configurable:!0}),t.prototype.activateNormalBuffer=function(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))},t.prototype.activateAltBuffer=function(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))},t.prototype.resize=function(e,t){this._normal.resize(e,t),this._alt.resize(e,t)},t.prototype.setupTabStops=function(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)},t}(r(844).Disposable);t.BufferSet=a},511:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)});Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;var o=r(482),s=r(643),a=r(3734),c=function(e){function t(){var t=null!==e&&e.apply(this,arguments)||this;return t.content=0,t.fg=0,t.bg=0,t.extended=new a.ExtendedAttrs,t.combinedData="",t}return n(t,e),t.fromCharData=function(e){var r=new t;return r.setFromCharData(e),r},t.prototype.isCombined=function(){return 2097152&this.content},t.prototype.getWidth=function(){return this.content>>22},t.prototype.getChars=function(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,o.stringFromCodePoint)(2097151&this.content):""},t.prototype.getCode=function(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content},t.prototype.setFromCharData=function(e){this.fg=e[s.CHAR_DATA_ATTR_INDEX],this.bg=0;var t=!1;if(e[s.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[s.CHAR_DATA_CHAR_INDEX].length){var r=e[s.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=r&&r<=56319){var i=e[s.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=i&&i<=57343?this.content=1024*(r-55296)+i-56320+65536|e[s.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[s.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[s.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[s.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[s.CHAR_DATA_WIDTH_INDEX]<<22)},t.prototype.getAsCharData=function(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]},t}(a.AttributeData);t.CellData=c},643:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=256,t.DEFAULT_ATTR=256|t.DEFAULT_COLOR<<9,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},4863:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)});Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;var o=r(8460),s=function(e){function t(r){var i=e.call(this)||this;return i.line=r,i._id=t._nextId++,i.isDisposed=!1,i._onDispose=new o.EventEmitter,i}return n(t,e),Object.defineProperty(t.prototype,"id",{get:function(){return this._id},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onDispose",{get:function(){return this._onDispose.event},enumerable:!1,configurable:!0}),t.prototype.dispose=function(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),e.prototype.dispose.call(this))},t._nextId=1,t}(r(844).Disposable);t.Marker=s},7116:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},2584:(e,t)=>{var r,i;Object.defineProperty(t,"__esModule",{value:!0}),t.C1_ESCAPED=t.C1=t.C0=void 0,function(e){e.NUL="\0",e.SOH="",e.STX="",e.ETX="",e.EOT="",e.ENQ="",e.ACK="",e.BEL="",e.BS="\b",e.HT="\t",e.LF="\n",e.VT="\v",e.FF="\f",e.CR="\r",e.SO="",e.SI="",e.DLE="",e.DC1="",e.DC2="",e.DC3="",e.DC4="",e.NAK="",e.SYN="",e.ETB="",e.CAN="",e.EM="",e.SUB="",e.ESC="",e.FS="",e.GS="",e.RS="",e.US="",e.SP=" ",e.DEL=""}(r=t.C0||(t.C0={})),(i=t.C1||(t.C1={})).PAD="€",i.HOP="",i.BPH="‚",i.NBH="ƒ",i.IND="„",i.NEL="…",i.SSA="†",i.ESA="‡",i.HTS="ˆ",i.HTJ="‰",i.VTS="Š",i.PLD="‹",i.PLU="Œ",i.RI="",i.SS2="Ž",i.SS3="",i.DCS="",i.PU1="‘",i.PU2="’",i.STS="“",i.CCH="”",i.MW="•",i.SPA="–",i.EPA="—",i.SOS="˜",i.SGCI="™",i.SCI="š",i.CSI="›",i.ST="œ",i.OSC="",i.PM="ž",i.APC="Ÿ",(t.C1_ESCAPED||(t.C1_ESCAPED={})).ST=r.ESC+"\\"},7399:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=void 0;var i=r(2584),n={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']};t.evaluateKeyboardEvent=function(e,t,r,o){var s={type:0,cancel:!1,key:void 0},a=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?s.key=t?i.C0.ESC+"OA":i.C0.ESC+"[A":"UIKeyInputLeftArrow"===e.key?s.key=t?i.C0.ESC+"OD":i.C0.ESC+"[D":"UIKeyInputRightArrow"===e.key?s.key=t?i.C0.ESC+"OC":i.C0.ESC+"[C":"UIKeyInputDownArrow"===e.key&&(s.key=t?i.C0.ESC+"OB":i.C0.ESC+"[B");break;case 8:if(e.shiftKey){s.key=i.C0.BS;break}if(e.altKey){s.key=i.C0.ESC+i.C0.DEL;break}s.key=i.C0.DEL;break;case 9:if(e.shiftKey){s.key=i.C0.ESC+"[Z";break}s.key=i.C0.HT,s.cancel=!0;break;case 13:s.key=e.altKey?i.C0.ESC+i.C0.CR:i.C0.CR,s.cancel=!0;break;case 27:s.key=i.C0.ESC,e.altKey&&(s.key=i.C0.ESC+i.C0.ESC),s.cancel=!0;break;case 37:if(e.metaKey)break;a?(s.key=i.C0.ESC+"[1;"+(a+1)+"D",s.key===i.C0.ESC+"[1;3D"&&(s.key=i.C0.ESC+(r?"b":"[1;5D"))):s.key=t?i.C0.ESC+"OD":i.C0.ESC+"[D";break;case 39:if(e.metaKey)break;a?(s.key=i.C0.ESC+"[1;"+(a+1)+"C",s.key===i.C0.ESC+"[1;3C"&&(s.key=i.C0.ESC+(r?"f":"[1;5C"))):s.key=t?i.C0.ESC+"OC":i.C0.ESC+"[C";break;case 38:if(e.metaKey)break;a?(s.key=i.C0.ESC+"[1;"+(a+1)+"A",r||s.key!==i.C0.ESC+"[1;3A"||(s.key=i.C0.ESC+"[1;5A")):s.key=t?i.C0.ESC+"OA":i.C0.ESC+"[A";break;case 40:if(e.metaKey)break;a?(s.key=i.C0.ESC+"[1;"+(a+1)+"B",r||s.key!==i.C0.ESC+"[1;3B"||(s.key=i.C0.ESC+"[1;5B")):s.key=t?i.C0.ESC+"OB":i.C0.ESC+"[B";break;case 45:e.shiftKey||e.ctrlKey||(s.key=i.C0.ESC+"[2~");break;case 46:s.key=a?i.C0.ESC+"[3;"+(a+1)+"~":i.C0.ESC+"[3~";break;case 36:s.key=a?i.C0.ESC+"[1;"+(a+1)+"H":t?i.C0.ESC+"OH":i.C0.ESC+"[H";break;case 35:s.key=a?i.C0.ESC+"[1;"+(a+1)+"F":t?i.C0.ESC+"OF":i.C0.ESC+"[F";break;case 33:e.shiftKey?s.type=2:e.ctrlKey?s.key=i.C0.ESC+"[5;"+(a+1)+"~":s.key=i.C0.ESC+"[5~";break;case 34:e.shiftKey?s.type=3:e.ctrlKey?s.key=i.C0.ESC+"[6;"+(a+1)+"~":s.key=i.C0.ESC+"[6~";break;case 112:s.key=a?i.C0.ESC+"[1;"+(a+1)+"P":i.C0.ESC+"OP";break;case 113:s.key=a?i.C0.ESC+"[1;"+(a+1)+"Q":i.C0.ESC+"OQ";break;case 114:s.key=a?i.C0.ESC+"[1;"+(a+1)+"R":i.C0.ESC+"OR";break;case 115:s.key=a?i.C0.ESC+"[1;"+(a+1)+"S":i.C0.ESC+"OS";break;case 116:s.key=a?i.C0.ESC+"[15;"+(a+1)+"~":i.C0.ESC+"[15~";break;case 117:s.key=a?i.C0.ESC+"[17;"+(a+1)+"~":i.C0.ESC+"[17~";break;case 118:s.key=a?i.C0.ESC+"[18;"+(a+1)+"~":i.C0.ESC+"[18~";break;case 119:s.key=a?i.C0.ESC+"[19;"+(a+1)+"~":i.C0.ESC+"[19~";break;case 120:s.key=a?i.C0.ESC+"[20;"+(a+1)+"~":i.C0.ESC+"[20~";break;case 121:s.key=a?i.C0.ESC+"[21;"+(a+1)+"~":i.C0.ESC+"[21~";break;case 122:s.key=a?i.C0.ESC+"[23;"+(a+1)+"~":i.C0.ESC+"[23~";break;case 123:s.key=a?i.C0.ESC+"[24;"+(a+1)+"~":i.C0.ESC+"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(r&&!o||!e.altKey||e.metaKey)!r||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey?e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length?s.key=e.key:e.key&&e.ctrlKey&&("_"===e.key&&(s.key=i.C0.US),"@"===e.key&&(s.key=i.C0.NUL)):65===e.keyCode&&(s.type=1);else{var c=n[e.keyCode],l=null==c?void 0:c[e.shiftKey?1:0];if(l)s.key=i.C0.ESC+l;else if(e.keyCode>=65&&e.keyCode<=90){var h=e.ctrlKey?e.keyCode-64:e.keyCode+32,u=String.fromCharCode(h);e.shiftKey&&(u=u.toUpperCase()),s.key=i.C0.ESC+u}else"Dead"===e.key&&e.code.startsWith("Key")&&(u=e.code.slice(3,4),e.shiftKey||(u=u.toLowerCase()),s.key=i.C0.ESC+u,s.cancel=!0)}else e.keyCode>=65&&e.keyCode<=90?s.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?s.key=i.C0.NUL:e.keyCode>=51&&e.keyCode<=55?s.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?s.key=i.C0.DEL:219===e.keyCode?s.key=i.C0.ESC:220===e.keyCode?s.key=i.C0.FS:221===e.keyCode&&(s.key=i.C0.GS)}return s}},482:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=t.utf32ToString=t.stringFromCodePoint=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t,r){void 0===t&&(t=0),void 0===r&&(r=e.length);for(var i="",n=t;n65535?(o-=65536,i+=String.fromCharCode(55296+(o>>10))+String.fromCharCode(o%1024+56320)):i+=String.fromCharCode(o)}return i};var r=function(){function e(){this._interim=0}return e.prototype.clear=function(){this._interim=0},e.prototype.decode=function(e,t){var r=e.length;if(!r)return 0;var i=0,n=0;this._interim&&(56320<=(a=e.charCodeAt(n++))&&a<=57343?t[i++]=1024*(this._interim-55296)+a-56320+65536:(t[i++]=this._interim,t[i++]=a),this._interim=0);for(var o=n;o=r)return this._interim=s,i;var a;56320<=(a=e.charCodeAt(o))&&a<=57343?t[i++]=1024*(s-55296)+a-56320+65536:(t[i++]=s,t[i++]=a)}else 65279!==s&&(t[i++]=s)}return i},e}();t.StringToUtf32=r;var i=function(){function e(){this.interim=new Uint8Array(3)}return e.prototype.clear=function(){this.interim.fill(0)},e.prototype.decode=function(e,t){var r=e.length;if(!r)return 0;var i,n,o,s,a=0,c=0,l=0;if(this.interim[0]){var h=!1,u=this.interim[0];u&=192==(224&u)?31:224==(240&u)?15:7;for(var f=0,_=void 0;(_=63&this.interim[++f])&&f<4;)u<<=6,u|=_;for(var d=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,p=d-f;l=r)return 0;if(128!=(192&(_=e[l++]))){l--,h=!0;break}this.interim[f++]=_,u<<=6,u|=63&_}h||(2===d?u<128?l--:t[a++]=u:3===d?u<2048||u>=55296&&u<=57343||65279===u||(t[a++]=u):u<65536||u>1114111||(t[a++]=u)),this.interim.fill(0)}for(var v=r-4,y=l;y=r)return this.interim[0]=i,a;if(128!=(192&(n=e[y++]))){y--;continue}if((c=(31&i)<<6|63&n)<128){y--;continue}t[a++]=c}else if(224==(240&i)){if(y>=r)return this.interim[0]=i,a;if(128!=(192&(n=e[y++]))){y--;continue}if(y>=r)return this.interim[0]=i,this.interim[1]=n,a;if(128!=(192&(o=e[y++]))){y--;continue}if((c=(15&i)<<12|(63&n)<<6|63&o)<2048||c>=55296&&c<=57343||65279===c)continue;t[a++]=c}else if(240==(248&i)){if(y>=r)return this.interim[0]=i,a;if(128!=(192&(n=e[y++]))){y--;continue}if(y>=r)return this.interim[0]=i,this.interim[1]=n,a;if(128!=(192&(o=e[y++]))){y--;continue}if(y>=r)return this.interim[0]=i,this.interim[1]=n,this.interim[2]=o,a;if(128!=(192&(s=e[y++]))){y--;continue}if((c=(7&i)<<18|(63&n)<<12|(63&o)<<6|63&s)<65536||c>1114111)continue;t[a++]=c}}return a},e}();t.Utf8ToUtf32=i},225:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;var i,n=r(8273),o=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],s=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]],a=function(){function e(){if(this.version="6",!i){i=new Uint8Array(65536),(0,n.fill)(i,1),i[0]=0,(0,n.fill)(i,0,1,32),(0,n.fill)(i,0,127,160),(0,n.fill)(i,2,4352,4448),i[9001]=2,i[9002]=2,(0,n.fill)(i,2,11904,42192),i[12351]=1,(0,n.fill)(i,2,44032,55204),(0,n.fill)(i,2,63744,64256),(0,n.fill)(i,2,65040,65050),(0,n.fill)(i,2,65072,65136),(0,n.fill)(i,2,65280,65377),(0,n.fill)(i,2,65504,65511);for(var e=0;et[n][1])return!1;for(;n>=i;)if(e>t[r=i+n>>1][1])i=r+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1},e}();t.UnicodeV6=a},5981:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;var i=r(8460),n="undefined"==typeof queueMicrotask?function(e){Promise.resolve().then(e)}:queueMicrotask,o=function(){function e(e){this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._onWriteParsed=new i.EventEmitter}return Object.defineProperty(e.prototype,"onWriteParsed",{get:function(){return this._onWriteParsed.event},enumerable:!1,configurable:!0}),e.prototype.writeSync=function(e,t){if(void 0!==t&&this._syncCalls>t)this._syncCalls=0;else if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,!this._isSyncWriting){var r;for(this._isSyncWriting=!0;r=this._writeBuffer.shift();){this._action(r);var i=this._callbacks.shift();i&&i()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}},e.prototype.write=function(e,t){var r=this;if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");this._writeBuffer.length||(this._bufferOffset=0,setTimeout((function(){return r._innerWrite()}))),this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)},e.prototype._innerWrite=function(e,t){var r=this;void 0===e&&(e=0),void 0===t&&(t=!0);for(var i=e||Date.now();this._writeBuffer.length>this._bufferOffset;){var o=this._writeBuffer[this._bufferOffset],s=this._action(o,t);if(s)return void s.catch((function(e){return n((function(){throw e})),Promise.resolve(!1)})).then((function(e){return Date.now()-i>=12?setTimeout((function(){return r._innerWrite(0,e)})):r._innerWrite(i,e)}));var a=this._callbacks[this._bufferOffset];if(a&&a(),this._bufferOffset++,this._pendingData-=o.length,Date.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),setTimeout((function(){return r._innerWrite()}))):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()},e}();t.WriteBuffer=o},5941:function(e,t){var r=this&&this.__read||function(e,t){var r="function"==typeof Symbol&&e[Symbol.iterator];if(!r)return e;var i,n,o=r.call(e),s=[];try{for(;(void 0===t||t-- >0)&&!(i=o.next()).done;)s.push(i.value)}catch(e){n={error:e}}finally{try{i&&!i.done&&(r=o.return)&&r.call(o)}finally{if(n)throw n.error}}return s};Object.defineProperty(t,"__esModule",{value:!0}),t.toRgbString=t.parseColor=void 0;var i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,n=/^[\da-f]+$/;function o(e,t){var r=e.toString(16),i=r.length<2?"0"+r:r;switch(t){case 4:return r[0];case 8:return i;case 12:return(i+i).slice(0,3);default:return i+i}}t.parseColor=function(e){if(e){var t=e.toLowerCase();if(0===t.indexOf("rgb:")){t=t.slice(4);var r=i.exec(t);if(r){var o=r[1]?15:r[4]?255:r[7]?4095:65535;return[Math.round(parseInt(r[1]||r[4]||r[7]||r[10],16)/o*255),Math.round(parseInt(r[2]||r[5]||r[8]||r[11],16)/o*255),Math.round(parseInt(r[3]||r[6]||r[9]||r[12],16)/o*255)]}}else if(0===t.indexOf("#")&&(t=t.slice(1),n.exec(t)&&[3,6,9,12].includes(t.length))){for(var s=t.length/3,a=[0,0,0],c=0;c<3;++c){var l=parseInt(t.slice(s*c,s*c+s),16);a[c]=1===s?l<<4:2===s?l:3===s?l>>4:l>>8}return a}}},t.toRgbString=function(e,t){void 0===t&&(t=16);var i=r(e,3),n=i[0],s=i[1],a=i[2];return"rgb:"+o(n,t)+"/"+o(s,t)+"/"+o(a,t)}},5770:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.PAYLOAD_LIMIT=void 0,t.PAYLOAD_LIMIT=1e7},6351:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;var i=r(482),n=r(8742),o=r(5770),s=[],a=function(){function e(){this._handlers=Object.create(null),this._active=s,this._ident=0,this._handlerFb=function(){},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}return e.prototype.dispose=function(){this._handlers=Object.create(null),this._handlerFb=function(){},this._active=s},e.prototype.registerHandler=function(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);var r=this._handlers[e];return r.push(t),{dispose:function(){var e=r.indexOf(t);-1!==e&&r.splice(e,1)}}},e.prototype.clearHandler=function(e){this._handlers[e]&&delete this._handlers[e]},e.prototype.setHandlerFallback=function(e){this._handlerFb=e},e.prototype.reset=function(){if(this._active.length)for(var e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=s,this._ident=0},e.prototype.hook=function(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||s,this._active.length)for(var r=this._active.length-1;r>=0;r--)this._active[r].hook(t);else this._handlerFb(this._ident,"HOOK",t)},e.prototype.put=function(e,t,r){if(this._active.length)for(var n=this._active.length-1;n>=0;n--)this._active[n].put(e,t,r);else this._handlerFb(this._ident,"PUT",(0,i.utf32ToString)(e,t,r))},e.prototype.unhook=function(e,t){if(void 0===t&&(t=!0),this._active.length){var r=!1,i=this._active.length-1,n=!1;if(this._stack.paused&&(i=this._stack.loopPosition-1,r=t,n=this._stack.fallThrough,this._stack.paused=!1),!n&&!1===r){for(;i>=0&&!0!==(r=this._active[i].unhook(e));i--)if(r instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=i,this._stack.fallThrough=!1,r;i--}for(;i>=0;i--)if((r=this._active[i].unhook(!1))instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=i,this._stack.fallThrough=!0,r}else this._handlerFb(this._ident,"UNHOOK",e);this._active=s,this._ident=0},e}();t.DcsParser=a;var c=new n.Params;c.addParam(0);var l=function(){function e(e){this._handler=e,this._data="",this._params=c,this._hitLimit=!1}return e.prototype.hook=function(e){this._params=e.length>1||e.params[0]?e.clone():c,this._data="",this._hitLimit=!1},e.prototype.put=function(e,t,r){this._hitLimit||(this._data+=(0,i.utf32ToString)(e,t,r),this._data.length>o.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))},e.prototype.unhook=function(e){var t=this,r=!1;if(this._hitLimit)r=!1;else if(e&&(r=this._handler(this._data,this._params))instanceof Promise)return r.then((function(e){return t._params=c,t._data="",t._hitLimit=!1,e}));return this._params=c,this._data="",this._hitLimit=!1,r},e}();t.DcsHandler=l},2015:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)});Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;var o=r(844),s=r(8273),a=r(8742),c=r(6242),l=r(6351),h=function(){function e(e){this.table=new Uint8Array(e)}return e.prototype.setDefault=function(e,t){(0,s.fill)(this.table,e<<4|t)},e.prototype.add=function(e,t,r,i){this.table[t<<8|e]=r<<4|i},e.prototype.addMany=function(e,t,r,i){for(var n=0;n1)throw new Error("only one byte as prefix supported");if((r=e.prefix.charCodeAt(0))&&60>r||r>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(var i=0;in||n>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");r<<=8,r|=n}}if(1!==e.final.length)throw new Error("final must be a single byte");var o=e.final.charCodeAt(0);if(t[0]>o||o>t[1])throw new Error("final must be in range "+t[0]+" .. "+t[1]);return(r<<=8)|o},r.prototype.identToString=function(e){for(var t=[];e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")},r.prototype.dispose=function(){this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._escHandlers=Object.create(null),this._oscParser.dispose(),this._dcsParser.dispose()},r.prototype.setPrintHandler=function(e){this._printHandler=e},r.prototype.clearPrintHandler=function(){this._printHandler=this._printHandlerFb},r.prototype.registerEscHandler=function(e,t){var r=this._identifier(e,[48,126]);void 0===this._escHandlers[r]&&(this._escHandlers[r]=[]);var i=this._escHandlers[r];return i.push(t),{dispose:function(){var e=i.indexOf(t);-1!==e&&i.splice(e,1)}}},r.prototype.clearEscHandler=function(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]},r.prototype.setEscHandlerFallback=function(e){this._escHandlerFb=e},r.prototype.setExecuteHandler=function(e,t){this._executeHandlers[e.charCodeAt(0)]=t},r.prototype.clearExecuteHandler=function(e){this._executeHandlers[e.charCodeAt(0)]&&delete this._executeHandlers[e.charCodeAt(0)]},r.prototype.setExecuteHandlerFallback=function(e){this._executeHandlerFb=e},r.prototype.registerCsiHandler=function(e,t){var r=this._identifier(e);void 0===this._csiHandlers[r]&&(this._csiHandlers[r]=[]);var i=this._csiHandlers[r];return i.push(t),{dispose:function(){var e=i.indexOf(t);-1!==e&&i.splice(e,1)}}},r.prototype.clearCsiHandler=function(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]},r.prototype.setCsiHandlerFallback=function(e){this._csiHandlerFb=e},r.prototype.registerDcsHandler=function(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)},r.prototype.clearDcsHandler=function(e){this._dcsParser.clearHandler(this._identifier(e))},r.prototype.setDcsHandlerFallback=function(e){this._dcsParser.setHandlerFallback(e)},r.prototype.registerOscHandler=function(e,t){return this._oscParser.registerHandler(e,t)},r.prototype.clearOscHandler=function(e){this._oscParser.clearHandler(e)},r.prototype.setOscHandlerFallback=function(e){this._oscParser.setHandlerFallback(e)},r.prototype.setErrorHandler=function(e){this._errorHandler=e},r.prototype.clearErrorHandler=function(){this._errorHandler=this._errorHandlerFb},r.prototype.reset=function(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._params.reset(),this._params.addParam(0),this._collect=0,this.precedingCodepoint=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])},r.prototype._preserveStack=function(e,t,r,i,n){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=r,this._parseStack.transition=i,this._parseStack.chunkPos=n},r.prototype.parse=function(e,t,r){var i,n=0,o=0,s=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,s=this._parseStack.chunkPos+1;else{if(void 0===r||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");var a=this._parseStack.handlers,c=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===r&&c>-1)for(;c>=0&&!0!==(i=a[c](this._params));c--)if(i instanceof Promise)return this._parseStack.handlerPos=c,i;this._parseStack.handlers=[];break;case 4:if(!1===r&&c>-1)for(;c>=0&&!0!==(i=a[c]());c--)if(i instanceof Promise)return this._parseStack.handlerPos=c,i;this._parseStack.handlers=[];break;case 6:if(n=e[this._parseStack.chunkPos],i=this._dcsParser.unhook(24!==n&&26!==n,r))return i;27===n&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 5:if(n=e[this._parseStack.chunkPos],i=this._oscParser.end(24!==n&&26!==n,r))return i;27===n&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0}this._parseStack.state=0,s=this._parseStack.chunkPos+1,this.precedingCodepoint=0,this.currentState=15&this._parseStack.transition}for(var l=s;l>4){case 2:for(var h=l+1;;++h){if(h>=t||(n=e[h])<32||n>126&&n=t||(n=e[h])<32||n>126&&n=t||(n=e[h])<32||n>126&&n=t||(n=e[h])<32||n>126&&n=0&&!0!==(i=a[f](this._params));f--)if(i instanceof Promise)return this._preserveStack(3,a,f,o,l),i;f<0&&this._csiHandlerFb(this._collect<<8|n,this._params),this.precedingCodepoint=0;break;case 8:do{switch(n){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(n-48)}}while(++l47&&n<60);l--;break;case 9:this._collect<<=8,this._collect|=n;break;case 10:for(var _=this._escHandlers[this._collect<<8|n],d=_?_.length-1:-1;d>=0&&!0!==(i=_[d]());d--)if(i instanceof Promise)return this._preserveStack(4,_,d,o,l),i;d<0&&this._escHandlerFb(this._collect<<8|n),this.precedingCodepoint=0;break;case 11:this._params.reset(),this._params.addParam(0),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|n,this._params);break;case 13:for(var p=l+1;;++p)if(p>=t||24===(n=e[p])||26===n||27===n||n>127&&n=t||(n=e[v])<32||n>127&&n{Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;var i=r(5770),n=r(482),o=[],s=function(){function e(){this._state=0,this._active=o,this._id=-1,this._handlers=Object.create(null),this._handlerFb=function(){},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}return e.prototype.registerHandler=function(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);var r=this._handlers[e];return r.push(t),{dispose:function(){var e=r.indexOf(t);-1!==e&&r.splice(e,1)}}},e.prototype.clearHandler=function(e){this._handlers[e]&&delete this._handlers[e]},e.prototype.setHandlerFallback=function(e){this._handlerFb=e},e.prototype.dispose=function(){this._handlers=Object.create(null),this._handlerFb=function(){},this._active=o},e.prototype.reset=function(){if(2===this._state)for(var e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=o,this._id=-1,this._state=0},e.prototype._start=function(){if(this._active=this._handlers[this._id]||o,this._active.length)for(var e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")},e.prototype._put=function(e,t,r){if(this._active.length)for(var i=this._active.length-1;i>=0;i--)this._active[i].put(e,t,r);else this._handlerFb(this._id,"PUT",(0,n.utf32ToString)(e,t,r))},e.prototype.start=function(){this.reset(),this._state=1},e.prototype.put=function(e,t,r){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,r)}},e.prototype.end=function(e,t){if(void 0===t&&(t=!0),0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){var r=!1,i=this._active.length-1,n=!1;if(this._stack.paused&&(i=this._stack.loopPosition-1,r=t,n=this._stack.fallThrough,this._stack.paused=!1),!n&&!1===r){for(;i>=0&&!0!==(r=this._active[i].end(e));i--)if(r instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=i,this._stack.fallThrough=!1,r;i--}for(;i>=0;i--)if((r=this._active[i].end(!1))instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=i,this._stack.fallThrough=!0,r}else this._handlerFb(this._id,"END",e);this._active=o,this._id=-1,this._state=0}},e}();t.OscParser=s;var a=function(){function e(e){this._handler=e,this._data="",this._hitLimit=!1}return e.prototype.start=function(){this._data="",this._hitLimit=!1},e.prototype.put=function(e,t,r){this._hitLimit||(this._data+=(0,n.utf32ToString)(e,t,r),this._data.length>i.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))},e.prototype.end=function(e){var t=this,r=!1;if(this._hitLimit)r=!1;else if(e&&(r=this._handler(this._data))instanceof Promise)return r.then((function(e){return t._data="",t._hitLimit=!1,e}));return this._data="",this._hitLimit=!1,r},e}();t.OscHandler=a},8742:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;var r=2147483647,i=function(){function e(e,t){if(void 0===e&&(e=32),void 0===t&&(t=32),this.maxLength=e,this.maxSubParamsLength=t,t>256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}return e.fromArray=function(t){var r=new e;if(!t.length)return r;for(var i=Array.isArray(t[0])?1:0;i>8,i=255&this._subParamsIdx[t];i-r>0&&e.push(Array.prototype.slice.call(this._subParams,r,i))}return e},e.prototype.reset=function(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1},e.prototype.addParam=function(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>r?r:e}},e.prototype.addSubParam=function(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParams[this._subParamsLength++]=e>r?r:e,this._subParamsIdx[this.length-1]++}},e.prototype.hasSubParams=function(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0},e.prototype.getSubParams=function(e){var t=this._subParamsIdx[e]>>8,r=255&this._subParamsIdx[e];return r-t>0?this._subParams.subarray(t,r):null},e.prototype.getSubParamsAll=function(){for(var e={},t=0;t>8,i=255&this._subParamsIdx[t];i-r>0&&(e[t]=this._subParams.slice(r,i))}return e},e.prototype.addDigit=function(e){var t;if(!(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)){var i=this._digitIsSub?this._subParams:this.params,n=i[t-1];i[t-1]=~n?Math.min(10*n+e,r):e}},e}();t.Params=i},5741:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0;var r=function(){function e(){this._addons=[]}return e.prototype.dispose=function(){for(var e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()},e.prototype.loadAddon=function(e,t){var r=this,i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=function(){return r._wrappedAddonDispose(i)},t.activate(e)},e.prototype._wrappedAddonDispose=function(e){if(!e.isDisposed){for(var t=-1,r=0;r{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferApiView=void 0;var i=r(3785),n=r(511),o=function(){function e(e,t){this._buffer=e,this.type=t}return e.prototype.init=function(e){return this._buffer=e,this},Object.defineProperty(e.prototype,"cursorY",{get:function(){return this._buffer.y},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"cursorX",{get:function(){return this._buffer.x},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"viewportY",{get:function(){return this._buffer.ydisp},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"baseY",{get:function(){return this._buffer.ybase},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"length",{get:function(){return this._buffer.lines.length},enumerable:!1,configurable:!0}),e.prototype.getLine=function(e){var t=this._buffer.lines.get(e);if(t)return new i.BufferLineApiView(t)},e.prototype.getNullCell=function(){return new n.CellData},e}();t.BufferApiView=o},3785:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLineApiView=void 0;var i=r(511),n=function(){function e(e){this._line=e}return Object.defineProperty(e.prototype,"isWrapped",{get:function(){return this._line.isWrapped},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"length",{get:function(){return this._line.length},enumerable:!1,configurable:!0}),e.prototype.getCell=function(e,t){if(!(e<0||e>=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new i.CellData)},e.prototype.translateToString=function(e,t,r){return this._line.translateToString(e,t,r)},e}();t.BufferLineApiView=n},8285:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;var i=r(8771),n=r(8460),o=function(){function e(e){var t=this;this._core=e,this._onBufferChange=new n.EventEmitter,this._normal=new i.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new i.BufferApiView(this._core.buffers.alt,"alternate"),this._core.buffers.onBufferActivate((function(){return t._onBufferChange.fire(t.active)}))}return Object.defineProperty(e.prototype,"onBufferChange",{get:function(){return this._onBufferChange.event},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"active",{get:function(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"normal",{get:function(){return this._normal.init(this._core.buffers.normal)},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"alternate",{get:function(){return this._alternate.init(this._core.buffers.alt)},enumerable:!1,configurable:!0}),e}();t.BufferNamespaceApi=o},7975:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0;var r=function(){function e(e){this._core=e}return e.prototype.registerCsiHandler=function(e,t){return this._core.registerCsiHandler(e,(function(e){return t(e.toArray())}))},e.prototype.addCsiHandler=function(e,t){return this.registerCsiHandler(e,t)},e.prototype.registerDcsHandler=function(e,t){return this._core.registerDcsHandler(e,(function(e,r){return t(e,r.toArray())}))},e.prototype.addDcsHandler=function(e,t){return this.registerDcsHandler(e,t)},e.prototype.registerEscHandler=function(e,t){return this._core.registerEscHandler(e,t)},e.prototype.addEscHandler=function(e,t){return this.registerEscHandler(e,t)},e.prototype.registerOscHandler=function(e,t){return this._core.registerOscHandler(e,t)},e.prototype.addOscHandler=function(e,t){return this.registerOscHandler(e,t)},e}();t.ParserApi=r},7090:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0;var r=function(){function e(e){this._core=e}return e.prototype.register=function(e){this._core.unicodeService.register(e)},Object.defineProperty(e.prototype,"versions",{get:function(){return this._core.unicodeService.versions},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"activeVersion",{get:function(){return this._core.unicodeService.activeVersion},set:function(e){this._core.unicodeService.activeVersion=e},enumerable:!1,configurable:!0}),e}();t.UnicodeApi=r},744:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)}),o=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},s=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=t.MINIMUM_ROWS=t.MINIMUM_COLS=void 0;var a=r(2585),c=r(5295),l=r(8460),h=r(844);t.MINIMUM_COLS=2,t.MINIMUM_ROWS=1;var u=function(e){function r(r){var i=e.call(this)||this;return i._optionsService=r,i.isUserScrolling=!1,i._onResize=new l.EventEmitter,i._onScroll=new l.EventEmitter,i.cols=Math.max(r.rawOptions.cols||0,t.MINIMUM_COLS),i.rows=Math.max(r.rawOptions.rows||0,t.MINIMUM_ROWS),i.buffers=new c.BufferSet(r,i),i}return n(r,e),Object.defineProperty(r.prototype,"onResize",{get:function(){return this._onResize.event},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"onScroll",{get:function(){return this._onScroll.event},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"buffer",{get:function(){return this.buffers.active},enumerable:!1,configurable:!0}),r.prototype.dispose=function(){e.prototype.dispose.call(this),this.buffers.dispose()},r.prototype.resize=function(e,t){this.cols=e,this.rows=t,this.buffers.resize(e,t),this.buffers.setupTabStops(this.cols),this._onResize.fire({cols:e,rows:t})},r.prototype.reset=function(){this.buffers.reset(),this.isUserScrolling=!1},r.prototype.scroll=function(e,t){void 0===t&&(t=!1);var r,i=this.buffer;(r=this._cachedBlankLine)&&r.length===this.cols&&r.getFg(0)===e.fg&&r.getBg(0)===e.bg||(r=i.getBlankLine(e,t),this._cachedBlankLine=r),r.isWrapped=t;var n=i.ybase+i.scrollTop,o=i.ybase+i.scrollBottom;if(0===i.scrollTop){var s=i.lines.isFull;o===i.lines.length-1?s?i.lines.recycle().copyFrom(r):i.lines.push(r.clone()):i.lines.splice(o+1,0,r.clone()),s?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{var a=o-n+1;i.lines.shiftElements(n+1,a-1,-1),i.lines.set(o,r.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)},r.prototype.scrollLines=function(e,t,r){var i=this.buffer;if(e<0){if(0===i.ydisp)return;this.isUserScrolling=!0}else e+i.ydisp>=i.ybase&&(this.isUserScrolling=!1);var n=i.ydisp;i.ydisp=Math.max(Math.min(i.ydisp+e,i.ybase),0),n!==i.ydisp&&(t||this._onScroll.fire(i.ydisp))},r.prototype.scrollPages=function(e){this.scrollLines(e*(this.rows-1))},r.prototype.scrollToTop=function(){this.scrollLines(-this.buffer.ydisp)},r.prototype.scrollToBottom=function(){this.scrollLines(this.buffer.ybase-this.buffer.ydisp)},r.prototype.scrollToLine=function(e){var t=e-this.buffer.ydisp;0!==t&&this.scrollLines(t)},o([s(0,a.IOptionsService)],r)}(h.Disposable);t.BufferService=u},7994:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0;var r=function(){function e(){this.glevel=0,this._charsets=[]}return e.prototype.reset=function(){this.charset=void 0,this._charsets=[],this.glevel=0},e.prototype.setgLevel=function(e){this.glevel=e,this.charset=this._charsets[e]},e.prototype.setgCharset=function(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)},e}();t.CharsetService=r},1753:function(e,t,r){var i=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},n=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}},o=this&&this.__values||function(e){var t="function"==typeof Symbol&&Symbol.iterator,r=t&&e[t],i=0;if(r)return r.call(e);if(e&&"number"==typeof e.length)return{next:function(){return e&&i>=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreMouseService=void 0;var s=r(2585),a=r(8460),c={NONE:{events:0,restrict:function(){return!1}},X10:{events:1,restrict:function(e){return 4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)}},VT200:{events:19,restrict:function(e){return 32!==e.action}},DRAG:{events:23,restrict:function(e){return 32!==e.action||3!==e.button}},ANY:{events:31,restrict:function(e){return!0}}};function l(e,t){var r=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(r|=64,r|=e.action):(r|=3&e.button,4&e.button&&(r|=64),8&e.button&&(r|=128),32===e.action?r|=32:0!==e.action||t||(r|=3)),r}var h=String.fromCharCode,u={DEFAULT:function(e){var t=[l(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":""+h(t[0])+h(t[1])+h(t[2])},SGR:function(e){var t=0===e.action&&4!==e.button?"m":"M";return"[<"+l(e,!0)+";"+e.col+";"+e.row+t}},f=function(){function e(e,t){var r,i,n,s;this._bufferService=e,this._coreService=t,this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._onProtocolChange=new a.EventEmitter,this._lastEvent=null;try{for(var l=o(Object.keys(c)),h=l.next();!h.done;h=l.next()){var f=h.value;this.addProtocol(f,c[f])}}catch(e){r={error:e}}finally{try{h&&!h.done&&(i=l.return)&&i.call(l)}finally{if(r)throw r.error}}try{for(var _=o(Object.keys(u)),d=_.next();!d.done;d=_.next()){var p=d.value;this.addEncoding(p,u[p])}}catch(e){n={error:e}}finally{try{d&&!d.done&&(s=_.return)&&s.call(_)}finally{if(n)throw n.error}}this.reset()}return e.prototype.addProtocol=function(e,t){this._protocols[e]=t},e.prototype.addEncoding=function(e,t){this._encodings[e]=t},Object.defineProperty(e.prototype,"activeProtocol",{get:function(){return this._activeProtocol},set:function(e){if(!this._protocols[e])throw new Error('unknown protocol "'+e+'"');this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"areMouseEventsActive",{get:function(){return 0!==this._protocols[this._activeProtocol].events},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"activeEncoding",{get:function(){return this._activeEncoding},set:function(e){if(!this._encodings[e])throw new Error('unknown encoding "'+e+'"');this._activeEncoding=e},enumerable:!1,configurable:!0}),e.prototype.reset=function(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT",this._lastEvent=null},Object.defineProperty(e.prototype,"onProtocolChange",{get:function(){return this._onProtocolChange.event},enumerable:!1,configurable:!0}),e.prototype.triggerMouseEvent=function(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._compareEvents(this._lastEvent,e))return!1;if(!this._protocols[this._activeProtocol].restrict(e))return!1;var t=this._encodings[this._activeEncoding](e);return t&&("DEFAULT"===this._activeEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0},e.prototype.explainEvents=function(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}},e.prototype._compareEvents=function(e,t){return e.col===t.col&&e.row===t.row&&e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift},i([n(0,s.IBufferService),n(1,s.ICoreService)],e)}();t.CoreMouseService=f},6975:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)}),o=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},s=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;var a=r(2585),c=r(8460),l=r(1439),h=r(844),u=Object.freeze({insertMode:!1}),f=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,origin:!1,reverseWraparound:!1,sendFocus:!1,wraparound:!0}),_=function(e){function t(t,r,i,n){var o=e.call(this)||this;return o._bufferService=r,o._logService=i,o._optionsService=n,o.isCursorInitialized=!1,o.isCursorHidden=!1,o._onData=o.register(new c.EventEmitter),o._onUserInput=o.register(new c.EventEmitter),o._onBinary=o.register(new c.EventEmitter),o._scrollToBottom=t,o.register({dispose:function(){return o._scrollToBottom=void 0}}),o.modes=(0,l.clone)(u),o.decPrivateModes=(0,l.clone)(f),o}return n(t,e),Object.defineProperty(t.prototype,"onData",{get:function(){return this._onData.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onUserInput",{get:function(){return this._onUserInput.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onBinary",{get:function(){return this._onBinary.event},enumerable:!1,configurable:!0}),t.prototype.reset=function(){this.modes=(0,l.clone)(u),this.decPrivateModes=(0,l.clone)(f)},t.prototype.triggerDataEvent=function(e,t){if(void 0===t&&(t=!1),!this._optionsService.rawOptions.disableStdin){var r=this._bufferService.buffer;r.ybase!==r.ydisp&&this._scrollToBottom(),t&&this._onUserInput.fire(),this._logService.debug('sending data "'+e+'"',(function(){return e.split("").map((function(e){return e.charCodeAt(0)}))})),this._onData.fire(e)}},t.prototype.triggerBinaryEvent=function(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug('sending binary "'+e+'"',(function(){return e.split("").map((function(e){return e.charCodeAt(0)}))})),this._onBinary.fire(e))},o([s(1,a.IBufferService),s(2,a.ILogService),s(3,a.IOptionsService)],t)}(h.Disposable);t.CoreService=_},9074:function(e,t,r){var i,n=this&&this.__extends||(i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},i(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)}),o=this&&this.__generator||function(e,t){var r,i,n,o,s={label:0,sent:function(){if(1&n[0])throw n[1];return n[1]},trys:[],ops:[]};return o={next:a(0),throw:a(1),return:a(2)},"function"==typeof Symbol&&(o[Symbol.iterator]=function(){return this}),o;function a(o){return function(a){return function(o){if(r)throw new TypeError("Generator is already executing.");for(;s;)try{if(r=1,i&&(n=2&o[0]?i.return:o[0]?i.throw||((n=i.return)&&n.call(i),0):i.next)&&!(n=n.call(i,o[1])).done)return n;switch(i=0,n&&(o=[2&o[0],n.value]),o[0]){case 0:case 1:n=o;break;case 4:return s.label++,{value:o[1],done:!1};case 5:s.label++,i=o[1],o=[0];continue;case 7:o=s.ops.pop(),s.trys.pop();continue;default:if(!((n=(n=s.trys).length>0&&n[n.length-1])||6!==o[0]&&2!==o[0])){s=0;continue}if(3===o[0]&&(!n||o[1]>n[0]&&o[1]=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationService=void 0;var a=r(8055),c=r(8460),l=r(844),h=r(6106),u=function(e){function t(){var t=e.call(this)||this;return t._decorations=new h.SortedList((function(e){return e.marker.line})),t._onDecorationRegistered=t.register(new c.EventEmitter),t._onDecorationRemoved=t.register(new c.EventEmitter),t}return n(t,e),Object.defineProperty(t.prototype,"onDecorationRegistered",{get:function(){return this._onDecorationRegistered.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"onDecorationRemoved",{get:function(){return this._onDecorationRemoved.event},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"decorations",{get:function(){return this._decorations.values()},enumerable:!1,configurable:!0}),t.prototype.registerDecoration=function(e){var t=this;if(!e.marker.isDisposed){var r=new f(e);if(r){var i=r.marker.onDispose((function(){return r.dispose()}));r.onDispose((function(){r&&(t._decorations.delete(r)&&t._onDecorationRemoved.fire(r),i.dispose())})),this._decorations.insert(r),this._onDecorationRegistered.fire(r)}return r}},t.prototype.reset=function(){var e,t;try{for(var r=s(this._decorations.values()),i=r.next();!i.done;i=r.next())i.value.dispose()}catch(t){e={error:t}}finally{try{i&&!i.done&&(t=r.return)&&t.call(r)}finally{if(e)throw e.error}}this._decorations.clear()},t.prototype.getDecorationsAtLine=function(e){return o(this,(function(t){return[2,this._decorations.getKeyIterator(e)]}))},t.prototype.getDecorationsAtCell=function(e,t,r){var i,n,a,c,l,h,u,f,_,d,p;return o(this,(function(o){switch(o.label){case 0:i=0,n=0,o.label=1;case 1:o.trys.push([1,6,7,8]),a=s(this._decorations.getKeyIterator(t)),c=a.next(),o.label=2;case 2:return c.done?[3,5]:(l=c.value,i=null!==(_=l.options.x)&&void 0!==_?_:0,n=i+(null!==(d=l.options.width)&&void 0!==d?d:1),!(e>=i&&e=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},n=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DirtyRowService=void 0;var o=r(2585),s=function(){function e(e){this._bufferService=e,this.clearRange()}return Object.defineProperty(e.prototype,"start",{get:function(){return this._start},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"end",{get:function(){return this._end},enumerable:!1,configurable:!0}),e.prototype.clearRange=function(){this._start=this._bufferService.buffer.y,this._end=this._bufferService.buffer.y},e.prototype.markDirty=function(e){ethis._end&&(this._end=e)},e.prototype.markRangeDirty=function(e,t){if(e>t){var r=e;e=t,t=r}ethis._end&&(this._end=t)},e.prototype.markAllDirty=function(){this.markRangeDirty(0,this._bufferService.rows-1)},i([n(0,o.IBufferService)],e)}();t.DirtyRowService=s},4348:function(e,t,r){var i=this&&this.__values||function(e){var t="function"==typeof Symbol&&Symbol.iterator,r=t&&e[t],i=0;if(r)return r.call(e);if(e&&"number"==typeof e.length)return{next:function(){return e&&i>=e.length&&(e=void 0),{value:e&&e[i++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")},n=this&&this.__read||function(e,t){var r="function"==typeof Symbol&&e[Symbol.iterator];if(!r)return e;var i,n,o=r.call(e),s=[];try{for(;(void 0===t||t-- >0)&&!(i=o.next()).done;)s.push(i.value)}catch(e){n={error:e}}finally{try{i&&!i.done&&(r=o.return)&&r.call(o)}finally{if(n)throw n.error}}return s},o=this&&this.__spreadArray||function(e,t,r){if(r||2===arguments.length)for(var i,n=0,o=t.length;n0?l[0].index:s.length;if(s.length!==p)throw new Error("[createInstance] First service dependency of "+e.name+" at position "+(p+1)+" conflicts with "+s.length+" static arguments");return new(e.bind.apply(e,o([void 0],n(o(o([],n(s),!1),n(h),!1)),!1)))},e}();t.InstantiationService=l},7866:function(e,t,r){var i=this&&this.__decorate||function(e,t,r,i){var n,o=arguments.length,s=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(e,t,r,i);else for(var a=e.length-1;a>=0;a--)(n=e[a])&&(s=(o<3?n(s):o>3?n(t,r,s):n(t,r))||s);return o>3&&s&&Object.defineProperty(t,r,s),s},n=this&&this.__param||function(e,t){return function(r,i){t(r,i,e)}},o=this&&this.__read||function(e,t){var r="function"==typeof Symbol&&e[Symbol.iterator];if(!r)return e;var i,n,o=r.call(e),s=[];try{for(;(void 0===t||t-- >0)&&!(i=o.next()).done;)s.push(i.value)}catch(e){n={error:e}}finally{try{i&&!i.done&&(r=o.return)&&r.call(o)}finally{if(n)throw n.error}}return s},s=this&&this.__spreadArray||function(e,t,r){if(r||2===arguments.length)for(var i,n=0,o=t.length;n{function r(e,t,r){t.di$target===t?t.di$dependencies.push({id:e,index:r}):(t.di$dependencies=[{id:e,index:r}],t.di$target=t)}Object.defineProperty(t,"__esModule",{value:!0}),t.createDecorator=t.getServiceDependencies=t.serviceRegistry=void 0,t.serviceRegistry=new Map,t.getServiceDependencies=function(e){return e.di$dependencies||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);var i=function(e,t,n){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");r(i,e,n)};return i.toString=function(){return e},t.serviceRegistry.set(e,i),i}},2585:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.IDirtyRowService=t.ICharsetService=t.ICoreService=t.ICoreMouseService=t.IBufferService=void 0;var i,n=r(8343);t.IBufferService=(0,n.createDecorator)("BufferService"),t.ICoreMouseService=(0,n.createDecorator)("CoreMouseService"),t.ICoreService=(0,n.createDecorator)("CoreService"),t.ICharsetService=(0,n.createDecorator)("CharsetService"),t.IDirtyRowService=(0,n.createDecorator)("DirtyRowService"),t.IInstantiationService=(0,n.createDecorator)("InstantiationService"),(i=t.LogLevelEnum||(t.LogLevelEnum={}))[i.DEBUG=0]="DEBUG",i[i.INFO=1]="INFO",i[i.WARN=2]="WARN",i[i.ERROR=3]="ERROR",i[i.OFF=4]="OFF",t.ILogService=(0,n.createDecorator)("LogService"),t.IOptionsService=(0,n.createDecorator)("OptionsService"),t.IUnicodeService=(0,n.createDecorator)("UnicodeService"),t.IDecorationService=(0,n.createDecorator)("DecorationService")},1480:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;var i=r(8460),n=r(225),o=function(){function e(){this._providers=Object.create(null),this._active="",this._onChange=new i.EventEmitter;var e=new n.UnicodeV6;this.register(e),this._active=e.version,this._activeProvider=e}return Object.defineProperty(e.prototype,"onChange",{get:function(){return this._onChange.event},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"versions",{get:function(){return Object.keys(this._providers)},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"activeVersion",{get:function(){return this._active},set:function(e){if(!this._providers[e])throw new Error('unknown Unicode version "'+e+'"');this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)},enumerable:!1,configurable:!0}),e.prototype.register=function(e){this._providers[e.version]=e},e.prototype.wcwidth=function(e){return this._activeProvider.wcwidth(e)},e.prototype.getStringCellWidth=function(e){for(var t=0,r=e.length,i=0;i=r)return t+this.wcwidth(n);var o=e.charCodeAt(i);56320<=o&&o<=57343?n=1024*(n-55296)+o-56320+65536:t+=this.wcwidth(o)}t+=this.wcwidth(n)}return t},e}();t.UnicodeService=o}},t={};return function r(i){var n=t[i];if(void 0!==n)return n.exports;var o=t[i]={exports:{}};return e[i].call(o.exports,o,o.exports,r),o.exports}(4389)})()})); +//# sourceMappingURL=xterm.js.map \ No newline at end of file diff --git a/remote-bob/job-agent/Dockerfile b/remote-bob/job-agent/Dockerfile new file mode 100644 index 00000000..e6ac7fc4 --- /dev/null +++ b/remote-bob/job-agent/Dockerfile @@ -0,0 +1,118 @@ +# Remote Bob — Go job-agent + tmux + ttyd architecture +# Multi-stage build: Go job-agent compiled in a builder stage, copied into the runtime image. + +# ── Stage 1: build the Go job-agent ────────────────────────────────────────── +# NOTE: UBI9 go-toolset is used for the build stage so the toolchain is from a +# Red Hat-maintained image. The runtime stage cannot use UBI9 because the +# job-agent runtime requires packages (Node.js 22, tmux, ttyd, gh CLI, locales) +# that are not available in the Red Hat UBI package ecosystem. +FROM registry.access.redhat.com/ubi9/go-toolset:latest AS go-builder +USER root + +WORKDIR /src +COPY job-agent/go.mod job-agent/go.sum ./ +RUN go mod download +COPY job-agent/cmd ./cmd +COPY job-agent/internal ./internal +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/job-agent ./cmd/job-agent + +# Prepare the Bob Shell tarball for the runtime stage. run.sh downloads the +# latest release from https://bob.ibm.com/releases?bob=shell and caches it in +# the repo root (never committed to git), then passes it via the +# BOBSHELL_TARBALL build arg. When the arg is empty (e.g. Code Engine source +# builds, which do not support build args), the latest release is downloaded +# inside the build from the official Bob Shell release source. +ARG BOBSHELL_TARBALL= +COPY ${BOBSHELL_TARBALL} /tmp/bobshell-src +RUN if [ -f /tmp/bobshell-src ]; then \ + cp /tmp/bobshell-src /tmp/bobshell-package; \ + else \ + curl -fsSL https://s3.us-south.cloud-object-storage.appdomain.cloud/bob-shell/bobshell2-version.txt -o /tmp/bobshell-version.txt \ + && BOBSHELL_VERSION="$(tr -d '[:space:]' < /tmp/bobshell-version.txt)" \ + && curl -fsSL "https://s3.us-south.cloud-object-storage.appdomain.cloud/bob-shell/bobshell-${BOBSHELL_VERSION}.tgz" -o /tmp/bobshell-package; \ + fi \ + && rm -rf /tmp/bobshell-src /tmp/bobshell-version.txt + +# ── Stage 2: runtime image ──────────────────────────────────────────────────── +# debian:bookworm-slim is the minimal official Debian image; used here because +# Node.js 22 and tmux are not available in the UBI ecosystem. +FROM debian:bookworm-slim + +# Install system dependencies, Node.js 22, tmux, jq, and locales +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash \ + curl \ + ca-certificates \ + gnupg \ + jq \ + tmux \ + netcat-openbsd \ + locales \ + && sed -i 's/^# *\(en_US.UTF-8\)/\1/' /etc/locale.gen \ + && locale-gen \ + && mkdir -p /etc/apt/keyrings \ + # Node.js 22 apt repo + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Set UTF-8 locale so Bob Shell / git / tmux emit Unicode instead of __ placeholders +ENV LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 \ + LANGUAGE=en_US:en + +# Install ttyd (terminal over WebSocket) with SHA-256 checksum verification. +# SHA-256 checksums sourced from the official ttyd 1.7.7 GitHub release. +# Only x86_64 (amd64) and aarch64 (arm64) are supported in this build. +RUN curl -fsSL "https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.$(uname -m)" -o /usr/local/bin/ttyd \ + && case "$(uname -m)" in \ + x86_64) echo "8a217c968aba172e0dbf3f34447218dc015bc4d5e59bf51db2f2cd12b7be4f55 /usr/local/bin/ttyd" | sha256sum -c - ;; \ + aarch64) echo "b38acadd89d1d396a0f5649aa52c539edbad07f4bc7348b27b4f4b7219dd4165 /usr/local/bin/ttyd" | sha256sum -c - ;; \ + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; \ + esac \ + && chmod +x /usr/local/bin/ttyd + +# Create a non-root user for running the job agent process. +RUN useradd -m -u 1001 -s /bin/bash jobagent + +# Install Bob Shell (API key supplied at runtime via BOBSHELL_API_KEY env var). +COPY --from=go-builder /tmp/bobshell-package /tmp/bobshell-package +RUN mv /tmp/bobshell-package /tmp/bobshell.tgz \ + && npm install -g /tmp/bobshell.tgz \ + && rm /tmp/bobshell.tgz \ + && bob --version + +# Pre-configure Bob Shell for the jobagent user +RUN mkdir -p /home/jobagent/.bob/settings \ + && jq -n '{"autoAcceptLicense":true,"autoAcceptIbmLicense":true,"telemetry":{"enabled":false},"autoTrustWorkspace":true,"approvalMode":"auto_approve","ibm":{"isNotFirstTime":true,"licenseConsent":true},"prev_version":"2.0.0","bobShell":{"autoUpdate":false,"lastRunVersion":"2.0.0"}}' \ + > /home/jobagent/.bob/settings/settings.json \ + && jq -n '{"version":1,"folders":{"/workspace":"TRUST_FOLDER"}}' > /home/jobagent/.bob/trustedFolders.json \ + && chown -R jobagent:jobagent /home/jobagent/.bob + +# Create workspace directory owned by jobagent +RUN mkdir -p /workspace && chown jobagent:jobagent /workspace + +# Create tmux config for the jobagent user +RUN echo "set -g history-limit 50000" > /home/jobagent/.tmux.conf \ + && echo "set -g mouse on" >> /home/jobagent/.tmux.conf \ + && echo "set -g status-style bg=blue,fg=white" >> /home/jobagent/.tmux.conf \ + && echo "set -g default-terminal \"tmux-256color\"" >> /home/jobagent/.tmux.conf \ + && echo "set -ga terminal-overrides \",xterm-256color:Tc\"" >> /home/jobagent/.tmux.conf \ + && chown jobagent:jobagent /home/jobagent/.tmux.conf + +# Set working directory +WORKDIR /workspace + +# Copy the Go job-agent binary +COPY --from=go-builder /out/job-agent /usr/local/bin/job-agent +RUN chmod +x /usr/local/bin/job-agent + +# Run as non-root +USER 1001 + +# Expose ttyd port (7080) and health check port (7081) +EXPOSE 7080 7081 + +CMD ["/usr/local/bin/job-agent"] diff --git a/remote-bob/job-agent/cmd/job-agent/main.go b/remote-bob/job-agent/cmd/job-agent/main.go new file mode 100644 index 00000000..464d0fa9 --- /dev/null +++ b/remote-bob/job-agent/cmd/job-agent/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "context" + "errors" + "os" + "os/signal" + "syscall" + + "github.com/CodeEngine/remote-bob-job-agent/internal/log" + "github.com/CodeEngine/remote-bob-job-agent/internal/tunnel" +) + +func main() { + cfg, err := tunnel.LoadConfig() + if err != nil { + log.Error("job_agent_config_failed", err) + os.Exit(1) + } + + rt := tunnel.NewRuntime(cfg) + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + if err := rt.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + log.Error("job_agent_failed", err, "agent_id", cfg.AgentID) + os.Exit(1) + } +} diff --git a/remote-bob/job-agent/go.mod b/remote-bob/job-agent/go.mod new file mode 100644 index 00000000..25d69164 --- /dev/null +++ b/remote-bob/job-agent/go.mod @@ -0,0 +1,8 @@ +module github.com/CodeEngine/remote-bob-job-agent + +go 1.22 + +require ( + github.com/creack/pty v1.1.24 + github.com/google/uuid v1.6.0 +) diff --git a/remote-bob/job-agent/go.sum b/remote-bob/job-agent/go.sum new file mode 100644 index 00000000..b5e8a7c4 --- /dev/null +++ b/remote-bob/job-agent/go.sum @@ -0,0 +1,4 @@ +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/remote-bob/job-agent/internal/log/log.go b/remote-bob/job-agent/internal/log/log.go new file mode 100644 index 00000000..92d6fa80 --- /dev/null +++ b/remote-bob/job-agent/internal/log/log.go @@ -0,0 +1,70 @@ +// Package log provides structured JSON logging using the standard library's slog. +package log + +import ( + "log/slog" + "os" + "time" +) + +var logger *slog.Logger + +func init() { + handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }) + logger = slog.New(handler) +} + +// Info logs an informational event with optional fields. +// Fields may be passed as a map[string]interface{} (single arg) or as +// alternating key/value pairs matching the slog variadic API. +func Info(event string, fields ...any) { + args := buildArgs(event, fields...) + logger.Info("", args...) +} + +// Warn logs a warning event with optional fields. +func Warn(event string, fields ...any) { + args := buildArgs(event, fields...) + logger.Warn("", args...) +} + +// Error logs an error event. err may be nil. +func Error(event string, err error, fields ...any) { + base := []any{"event", event, "ts", time.Now().UTC().Format(time.RFC3339)} + if err != nil { + base = append(base, "error", err.Error()) + } + base = append(base, flattenFields(fields...)...) + logger.Error("", base...) +} + +// Debug logs a debug event with optional fields. +func Debug(event string, fields ...any) { + args := buildArgs(event, fields...) + logger.Debug("", args...) +} + +// buildArgs prepends the standard event/ts keys and flattens any map or +// key/value fields passed by callers. +func buildArgs(event string, fields ...any) []any { + base := []any{"event", event, "ts", time.Now().UTC().Format(time.RFC3339)} + return append(base, flattenFields(fields...)...) +} + +// flattenFields accepts either a single map[string]interface{} argument +// (legacy call-site convention used throughout the job-agent) or alternating +// key/value pairs (slog convention). +func flattenFields(fields ...any) []any { + if len(fields) == 1 { + if m, ok := fields[0].(map[string]interface{}); ok { + out := make([]any, 0, len(m)*2) + for k, v := range m { + out = append(out, k, v) + } + return out + } + } + return fields +} diff --git a/remote-bob/job-agent/internal/tunnel/config.go b/remote-bob/job-agent/internal/tunnel/config.go new file mode 100644 index 00000000..de372552 --- /dev/null +++ b/remote-bob/job-agent/internal/tunnel/config.go @@ -0,0 +1,164 @@ +package tunnel + +import ( + "fmt" + "net/url" + "os" + "strings" + "time" +) + +const ( + defaultTTYDPort = "7080" + defaultHealthPort = "7081" + defaultWorkspace = "/workspace" + defaultBobMode = "interactive" + defaultIdleTimeout = 5 * time.Minute + defaultReconnectDelay = 2 * time.Second + defaultTTYDReadyWait = 30 * time.Second + defaultTmuxDeathGrace = 5 * time.Second + defaultRunTokenFile = "/secrets/run-token" +) + +// runTokenSecretPath is a package variable so tests can redirect it to a +// temporary file. +var runTokenSecretPath = defaultRunTokenFile + +// Config holds the job-agent tunnel daemon configuration, loaded from the +// environment and mounted secret files. +type Config struct { + AgentID string + RunToken string + GatewayWSS string + TTYDPort string + HealthPort string + Workspace string + BobMode string + BobShellAPIKey string + // BobApprovalMode overrides the approvalMode field in settings.json when + // non-empty. Loaded from BOB_APPROVAL_MODE env var. + BobApprovalMode string + // BobTelemetrySet is true when BOB_TELEMETRY_ENABLED is present in the env. + BobTelemetrySet bool + BobTelemetryEnabled bool + Lang string + LCAll string + IdleTimeout time.Duration + ReconnectDelay time.Duration + TTYDReadyTimeout time.Duration + // TmuxDeathGrace is how long the health server keeps serving 503 + // {"status":"unhealthy"} after the tmux session dies, before the + // graceful shutdown sequence proceeds. + TmuxDeathGrace time.Duration +} + +// LoadConfig reads configuration from the environment and mounted secret +// files. Required values (AGENT_ID, RUN_TOKEN, GATEWAY_WSS) fail fast with a +// descriptive error naming the missing variable. +func LoadConfig() (*Config, error) { + cfg := &Config{ + AgentID: os.Getenv("AGENT_ID"), + RunToken: os.Getenv("RUN_TOKEN"), + GatewayWSS: os.Getenv("GATEWAY_WSS"), + TTYDPort: getenv("TTYD_PORT", defaultTTYDPort), + HealthPort: getenv("HEALTH_PORT", defaultHealthPort), + Workspace: getenv("WORKSPACE", defaultWorkspace), + BobMode: getenv("BOB_MODE", defaultBobMode), + BobApprovalMode: os.Getenv("BOB_APPROVAL_MODE"), + Lang: getenv("LANG", "en_US.UTF-8"), + LCAll: getenv("LC_ALL", "en_US.UTF-8"), + IdleTimeout: durationFromEnv("IDLE_TIMEOUT_MS", defaultIdleTimeout), + ReconnectDelay: durationFromEnv("RECONNECT_DELAY_MS", defaultReconnectDelay), + TTYDReadyTimeout: durationFromEnv("TTYD_READY_TIMEOUT_MS", defaultTTYDReadyWait), + TmuxDeathGrace: durationFromEnv("TMUX_DEATH_GRACE_MS", defaultTmuxDeathGrace), + } + + if telRaw := os.Getenv("BOB_TELEMETRY_ENABLED"); telRaw != "" { + cfg.BobTelemetrySet = true + cfg.BobTelemetryEnabled = telRaw == "true" || telRaw == "1" + } + + if cfg.AgentID == "" { + return nil, fmt.Errorf("AGENT_ID is required") + } + if cfg.RunToken == "" { + // Fall back to the mounted secret file. + if token, err := readSecretFile(runTokenSecretPath); err == nil && token != "" { + cfg.RunToken = token + } else { + return nil, fmt.Errorf("RUN_TOKEN is required (set RUN_TOKEN or mount it at %s)", runTokenSecretPath) + } + } + if cfg.GatewayWSS == "" { + return nil, fmt.Errorf("GATEWAY_WSS is required") + } + + switch cfg.BobMode { + case "interactive", "plan", "auto": + default: + return nil, fmt.Errorf("invalid BOB_MODE %q: must be one of interactive, plan, auto", cfg.BobMode) + } + + apiKey := os.Getenv("BOBSHELL_API_KEY") + if apiKey == "" { + return nil, fmt.Errorf("BOBSHELL_API_KEY is required") + } + cfg.BobShellAPIKey = apiKey + + return cfg, nil +} + +// readSecretFile reads and trims a mounted secret file. +func readSecretFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return strings.TrimSpace(string(data)), nil +} + +// bobCommand returns the tmux pane command for the configured BOB_MODE. +func bobCommand(cfg *Config) string { + switch cfg.BobMode { + case "plan": + return "bob chat --auto-approve --trust --accept-license --mode autonomous-loop-planner" + case "auto": + return "bob chat --auto-approve --trust --accept-license --mode auto" + default: + return "bob chat --auto-approve --trust --accept-license" + } +} + +// controlURL builds the agent control WS URL: GATEWAY_WSS/ws/agent?agent=. +// The run token is never placed in the URL — it travels in the Authorization header. +func controlURL(gatewayWSS, agentID string) string { + base := strings.TrimSuffix(gatewayWSS, "/") + base = strings.TrimSuffix(base, "/ws") + return base + "/ws/agent?agent=" + url.QueryEscape(agentID) +} + +// relayURL builds the agent relay WS URL: GATEWAY_WSS/ws/relay?relayToken=. +func relayURL(gatewayWSS, relayToken string) string { + base := strings.TrimSuffix(gatewayWSS, "/") + base = strings.TrimSuffix(base, "/ws") + return base + "/ws/relay?relayToken=" + url.QueryEscape(relayToken) +} + +func getenv(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func durationFromEnv(key string, fallback time.Duration) time.Duration { + value := os.Getenv(key) + if value == "" { + return fallback + } + parsed, err := time.ParseDuration(value + "ms") + if err != nil { + return fallback + } + return parsed +} diff --git a/remote-bob/job-agent/internal/tunnel/config_test.go b/remote-bob/job-agent/internal/tunnel/config_test.go new file mode 100644 index 00000000..5bc8b934 --- /dev/null +++ b/remote-bob/job-agent/internal/tunnel/config_test.go @@ -0,0 +1,164 @@ +package tunnel + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// setConfigEnv sets the minimal valid env for LoadConfig. +// Tests that want to exercise a missing variable unset it after calling this. +func setConfigEnv(t *testing.T) { + t.Helper() + t.Setenv("AGENT_ID", "agent-1") + t.Setenv("RUN_TOKEN", "run-token-123") + t.Setenv("GATEWAY_WSS", "ws://gateway:8080/ws") + t.Setenv("BOBSHELL_API_KEY", "bob-key") + runTokenSecretPath = "/nonexistent/run-token" +} + +func TestLoadConfig_MissingAgentID(t *testing.T) { + setConfigEnv(t) + t.Setenv("AGENT_ID", "") + _, err := LoadConfig() + if err == nil || !strings.Contains(err.Error(), "AGENT_ID") { + t.Fatalf("LoadConfig() error = %v, want error naming AGENT_ID", err) + } +} + +func TestLoadConfig_MissingRunToken(t *testing.T) { + setConfigEnv(t) + t.Setenv("RUN_TOKEN", "") + _, err := LoadConfig() + if err == nil || !strings.Contains(err.Error(), "RUN_TOKEN") { + t.Fatalf("LoadConfig() error = %v, want error naming RUN_TOKEN", err) + } +} + +func TestLoadConfig_MissingGatewayWSS(t *testing.T) { + setConfigEnv(t) + t.Setenv("GATEWAY_WSS", "") + _, err := LoadConfig() + if err == nil || !strings.Contains(err.Error(), "GATEWAY_WSS") { + t.Fatalf("LoadConfig() error = %v, want error naming GATEWAY_WSS", err) + } +} + +func TestLoadConfig_Defaults(t *testing.T) { + setConfigEnv(t) + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() failed: %v", err) + } + if cfg.TTYDPort != "7080" { + t.Errorf("TTYDPort = %q, want 7080", cfg.TTYDPort) + } + if cfg.HealthPort != "7081" { + t.Errorf("HealthPort = %q, want 7081", cfg.HealthPort) + } + if cfg.Workspace != "/workspace" { + t.Errorf("Workspace = %q, want /workspace", cfg.Workspace) + } + if cfg.BobMode != "interactive" { + t.Errorf("BobMode = %q, want interactive", cfg.BobMode) + } + if cfg.IdleTimeout != 5*time.Minute { + t.Errorf("IdleTimeout = %v, want 5m", cfg.IdleTimeout) + } + if cfg.TmuxDeathGrace != 5*time.Second { + t.Errorf("TmuxDeathGrace = %v, want 5s", cfg.TmuxDeathGrace) + } +} + +func TestLoadConfig_RunTokenFromSecretFile(t *testing.T) { + setConfigEnv(t) + t.Setenv("RUN_TOKEN", "") + dir := t.TempDir() + secretPath := filepath.Join(dir, "run-token") + if err := os.WriteFile(secretPath, []byte(" secret-from-file\n"), 0o600); err != nil { + t.Fatal(err) + } + runTokenSecretPath = secretPath + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() failed: %v", err) + } + if cfg.RunToken != "secret-from-file" { + t.Errorf("RunToken = %q, want secret-from-file (trimmed)", cfg.RunToken) + } +} + +func TestLoadConfig_InvalidBobMode(t *testing.T) { + setConfigEnv(t) + t.Setenv("BOB_MODE", "bogus") + _, err := LoadConfig() + if err == nil || !strings.Contains(err.Error(), "BOB_MODE") { + t.Fatalf("LoadConfig() error = %v, want error naming BOB_MODE", err) + } +} + +func TestLoadConfig_IdleTimeoutConfigurable(t *testing.T) { + setConfigEnv(t) + t.Setenv("IDLE_TIMEOUT_MS", "1500") + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() failed: %v", err) + } + if cfg.IdleTimeout != 1500*time.Millisecond { + t.Errorf("IdleTimeout = %v, want 1.5s", cfg.IdleTimeout) + } +} + +func TestLoadConfig_TmuxDeathGraceConfigurable(t *testing.T) { + setConfigEnv(t) + t.Setenv("TMUX_DEATH_GRACE_MS", "2000") + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() failed: %v", err) + } + if cfg.TmuxDeathGrace != 2*time.Second { + t.Errorf("TmuxDeathGrace = %v, want 2s", cfg.TmuxDeathGrace) + } +} + +func TestBobCommandVariants(t *testing.T) { + cfg := &Config{BobMode: "interactive"} + interactive := bobCommand(cfg) + cfg.BobMode = "plan" + plan := bobCommand(cfg) + cfg.BobMode = "auto" + auto := bobCommand(cfg) + + if interactive == plan || plan == auto || interactive == auto { + t.Errorf("BOB_MODE variants must produce different tmux commands: interactive=%q plan=%q auto=%q", interactive, plan, auto) + } + if !strings.Contains(interactive, "bob chat --auto-approve --trust --accept-license") { + t.Errorf("interactive command = %q, want bob chat --auto-approve --trust --accept-license", interactive) + } + if !strings.Contains(plan, "autonomous-loop-planner") { + t.Errorf("plan command = %q, want autonomous-loop-planner mode", plan) + } +} + +func TestControlURL(t *testing.T) { + // GATEWAY_WSS ending in /ws + if got := controlURL("ws://gateway:8080/ws", "agent-1"); got != "ws://gateway:8080/ws/agent?agent=agent-1" { + t.Errorf("controlURL(/ws) = %q", got) + } + // GATEWAY_WSS without /ws suffix + if got := controlURL("wss://gateway.example.com", "agent-1"); got != "wss://gateway.example.com/ws/agent?agent=agent-1" { + t.Errorf("controlURL(no /ws) = %q", got) + } + // agent id is URL-escaped + if got := controlURL("ws://g/ws", "a b"); !strings.Contains(got, "agent=a+b") { + t.Errorf("controlURL escaping = %q", got) + } +} + +func TestRelayURL(t *testing.T) { + if got := relayURL("ws://gateway:8080/ws", "tok-1"); got != "ws://gateway:8080/ws/relay?relayToken=tok-1" { + t.Errorf("relayURL = %q", got) + } +} diff --git a/remote-bob/job-agent/internal/tunnel/control.go b/remote-bob/job-agent/internal/tunnel/control.go new file mode 100644 index 00000000..547bfcdb --- /dev/null +++ b/remote-bob/job-agent/internal/tunnel/control.go @@ -0,0 +1,475 @@ +package tunnel + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "sync" + "time" + + "github.com/CodeEngine/remote-bob-job-agent/internal/log" + "github.com/CodeEngine/remote-bob-job-agent/internal/ws" +) + +// Control message types on the agent control connection (JSON text frames). +const ( + controlRegister = "register" + controlRelayOpen = "relay-open" + controlRelayClose = "relay-close" +) + +// serviceRegistration is the register control message payload. +type serviceRegistration struct { + Name string `json:"name"` + Upstream string `json:"upstream"` +} + +// registerMessage is the agent → apiserver register control message. +type registerMessage struct { + Type string `json:"type"` + Services []serviceRegistration `json:"services"` +} + +// relayOpenMessage is the apiserver → agent relay-open control message. +type relayOpenMessage struct { + Type string `json:"type"` + RelayID string `json:"relay_id"` + Service string `json:"service"` + RelayToken string `json:"relay_token"` +} + +// relayCloseMessage is the apiserver → agent relay-close control message. +type relayCloseMessage struct { + Type string `json:"type"` + RelayID string `json:"relay_id"` +} + +// controlConn wraps the agent control WS connection with a write mutex. +// ws.Conn already serialises writes internally; this wrapper exists only to +// keep the WriteJSON helper consistent with the rest of the code. +type controlConn struct { + conn *ws.Conn +} + +func (c *controlConn) writeJSON(v interface{}) error { + return c.conn.WriteJSON(v) +} + +// relay tracks a single active browser relay: the upstream connection to the +// local service and the agent-side relay connection to the apiserver. +type relay struct { + id string + service string + upstream *ws.Conn + relayWS *ws.Conn + done chan struct{} + closeOnce sync.Once +} + +func (r *relay) close() { + r.closeOnce.Do(func() { + close(r.done) + if r.upstream != nil { + _ = r.upstream.Close() + } + if r.relayWS != nil { + _ = r.relayWS.Close() + } + }) +} + +// controlLoop owns the agent control connection: it dials /ws/agent with the +// run token in the Authorization header, registers the ttyd service, handles +// relay-open/relay-close control messages, and reconnects with backoff on +// control loss (re-registering without restarting tmux/ttyd). +type controlLoop struct { + cfg *Config + adapter *ttydAdapter + relaysMu sync.Mutex + relays map[string]*relay + // activeRelays is the count of live relays, used by the idle timer. + activeRelays int + // inFlight is the count of relay-open handlers currently establishing a + // relay (upstream dial in progress). It suppresses the idle timer during + // relay establishment. + inFlight int + // activityCh is notified on relay attach/detach so the idle timer can + // reset. + activityCh chan struct{} + // dialOpts holds common dial options (timeout is enforced via ctx). + dialOpts *ws.DialOptions +} + +func newControlLoop(cfg *Config, adapter *ttydAdapter) *controlLoop { + return &controlLoop{ + cfg: cfg, + adapter: adapter, + relays: make(map[string]*relay), + activityCh: make(chan struct{}, 16), + dialOpts: &ws.DialOptions{}, + } +} + +// run dials the control connection and handles control messages until ctx is +// cancelled. On control loss it reconnects with backoff and re-registers. +// If the server sends WS close code 4001 (deliberate termination), run returns +// errTerminated immediately without reconnecting. +func (cl *controlLoop) run(ctx context.Context) error { + backoff := cl.cfg.ReconnectDelay + for { + if ctx.Err() != nil { + return ctx.Err() + } + err := cl.controlOnce(ctx) + if ctx.Err() != nil { + return ctx.Err() + } + // If the apiserver sent a deliberate termination close frame, do not + // reconnect — propagate the sentinel so the caller can shut down. + if errors.Is(err, errTerminated) { + return errTerminated + } + if err != nil { + log.Warn("control_connection_lost", map[string]interface{}{ + "error": err.Error(), + }) + } + // Reconnect with backoff. A rejected re-dial (expired/invalid run + // token) is retried with the same backoff; the agent never crashes + // or spins on a rejected re-dial. + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff): + } + } +} + +// controlOnce establishes a single control connection, registers services, +// and handles control messages until the connection drops. +func (cl *controlLoop) controlOnce(ctx context.Context) error { + conn, err := cl.dialControl(ctx) + if err != nil { + return err + } + defer conn.conn.Close() + // Close the connection when the context is cancelled so the read loop + // unblocks and the loop can exit cleanly. + go func() { + <-ctx.Done() + _ = conn.conn.Close() + }() + log.Info("control_connected", map[string]interface{}{ + "agent_id": cl.cfg.AgentID, + }) + + if err := cl.register(conn); err != nil { + return err + } + log.Info("control_registered", map[string]interface{}{ + "agent_id": cl.cfg.AgentID, + "service": "ttyd", + }) + + // Read control messages. A single reader goroutine owns the connection; + // relay handling runs in its own goroutines. + for { + _, msg, err := conn.conn.ReadMessage() + if err != nil { + // Deliberate server-initiated termination: return the sentinel so + // the caller (run) does not reconnect. + if isCloseCode(conn.conn, err, closeCodeAgentTerminated) { + log.Info("control_terminated_by_server", map[string]interface{}{ + "agent_id": cl.cfg.AgentID, + }) + return errTerminated + } + return err + } + var header struct { + Type string `json:"type"` + } + if err := json.Unmarshal(msg, &header); err != nil { + log.Warn("control_invalid_message", map[string]interface{}{ + "agent_id": cl.cfg.AgentID, + }) + continue + } + switch header.Type { + case controlRelayOpen: + var open relayOpenMessage + if err := json.Unmarshal(msg, &open); err != nil { + log.Warn("control_relay_open_invalid", map[string]interface{}{ + "agent_id": cl.cfg.AgentID, + }) + continue + } + go cl.handleRelayOpen(ctx, conn, open) + case controlRelayClose: + var closeMsg relayCloseMessage + if err := json.Unmarshal(msg, &closeMsg); err != nil { + continue + } + cl.closeRelay(closeMsg.RelayID) + default: + // Unknown control messages are ignored (forward-compatible). + } + } +} + +// isCloseCode reports whether err is io.EOF from a WebSocket close frame with +// the given code. +func isCloseCode(conn *ws.Conn, err error, code int) bool { + return errors.Is(err, io.EOF) && ws.IsCloseError(conn, err, code) +} + +// dialControl dials GATEWAY_WSS/ws/agent?agent= with the run token +// in the Authorization: Bearer header (never in the URL). +func (cl *controlLoop) dialControl(ctx context.Context) (*controlConn, error) { + rawURL := controlURL(cl.cfg.GatewayWSS, cl.cfg.AgentID) + opts := &ws.DialOptions{ + ExtraHeaders: map[string]string{ + "Authorization": "Bearer " + cl.cfg.RunToken, + }, + } + conn, resp, err := ws.DialContext(ctx, rawURL, opts) + if err != nil { + if resp != nil { + return nil, fmt.Errorf("control dial failed (status %d): %w", resp.StatusCode, err) + } + return nil, fmt.Errorf("control dial failed: %w", err) + } + return &controlConn{conn: conn}, nil +} + +// register sends the register control message with the ttyd service. +func (cl *controlLoop) register(conn *controlConn) error { + return conn.writeJSON(registerMessage{ + Type: controlRegister, + Services: []serviceRegistration{ + {Name: "ttyd", Upstream: cl.adapter.upstreamURL}, + }, + }) +} + +// handleRelayOpen opens the upstream to the requested service, dials the +// relay with the one-time token, performs the ttyd handshake, forwards the +// first browser frame, then pipes frames opaquely in both directions. +func (cl *controlLoop) handleRelayOpen(ctx context.Context, control *controlConn, open relayOpenMessage) { + // Mark the relay as in-flight so the idle timer is suppressed while the + // upstream/relay are being established. + cl.relaysMu.Lock() + cl.inFlight++ + cl.relaysMu.Unlock() + defer func() { + cl.relaysMu.Lock() + cl.inFlight-- + cl.relaysMu.Unlock() + select { + case cl.activityCh <- struct{}{}: + default: + } + }() + + if open.Service != "ttyd" { + log.Warn("relay_open_unknown_service", map[string]interface{}{ + "relay_id": open.RelayID, + "service": open.Service, + }) + return + } + + // Open the upstream to ttyd (with the 1.7.7 handshake). If ttyd is not + // reachable (not ready, max-clients exceeded, or dead), fail cleanly and + // stay registered — the browser gets a clean close from the apiserver. + upstream, err := cl.adapter.dialUpstream(ctx) + if err != nil { + log.Warn("relay_open_upstream_failed", map[string]interface{}{ + "relay_id": open.RelayID, + "service": open.Service, + "error": err.Error(), + }) + return + } + + // Dial the agent relay connection with the one-time token. + relayConn, _, err := ws.DialContext(ctx, relayURL(cl.cfg.GatewayWSS, open.RelayToken), nil) + if err != nil { + _ = upstream.Close() + log.Warn("relay_open_relay_dial_failed", map[string]interface{}{ + "relay_id": open.RelayID, + "error": err.Error(), + }) + return + } + + r := &relay{ + id: open.RelayID, + service: open.Service, + upstream: upstream, + relayWS: relayConn, + done: make(chan struct{}), + } + cl.trackRelay(r, true) + defer cl.trackRelay(r, false) + defer r.close() + + // The first browser frame (a resize frame) is forwarded to ttyd before + // the opaque pipe starts. Read it from the relay connection. + _, firstPayload, err := relayConn.ReadMessage() + if err != nil { + log.Warn("relay_open_first_frame_failed", map[string]interface{}{ + "relay_id": open.RelayID, + "error": err.Error(), + }) + return + } + if err := cl.adapter.forwardFirstResize(upstream, firstPayload); err != nil { + log.Warn("relay_open_first_frame_forward_failed", map[string]interface{}{ + "relay_id": open.RelayID, + "error": err.Error(), + }) + return + } + + // Pipe opaquely in both directions. Each direction has exactly one + // reader on its source connection. + errCh := make(chan error, 2) + go pipeFrames(relayConn, upstream, errCh) + go pipeFrames(upstream, relayConn, errCh) + + select { + case <-ctx.Done(): + return + case <-r.done: + return + case err := <-errCh: + if err != nil { + log.Info("relay_pipe_closed", map[string]interface{}{ + "relay_id": open.RelayID, + "error": err.Error(), + }) + } + return + } +} + +// trackRelay adds or removes a relay from the active set and notifies the +// idle timer. +func (cl *controlLoop) trackRelay(r *relay, add bool) { + cl.relaysMu.Lock() + if add { + cl.relays[r.id] = r + cl.activeRelays++ + } else { + if _, ok := cl.relays[r.id]; ok { + delete(cl.relays, r.id) + cl.activeRelays-- + } + } + cl.relaysMu.Unlock() + select { + case cl.activityCh <- struct{}{}: + default: + } +} + +// closeRelay closes the upstream for a relay-close control message. +func (cl *controlLoop) closeRelay(relayID string) { + cl.relaysMu.Lock() + r, ok := cl.relays[relayID] + cl.relaysMu.Unlock() + if ok { + r.close() + } +} + +// hasActiveRelays reports whether any relay is currently live or being +// established. +func (cl *controlLoop) hasActiveRelays() bool { + cl.relaysMu.Lock() + defer cl.relaysMu.Unlock() + return cl.activeRelays > 0 || cl.inFlight > 0 +} + +// closeAllRelays closes every active relay (graceful shutdown). +func (cl *controlLoop) closeAllRelays() { + cl.relaysMu.Lock() + relays := make([]*relay, 0, len(cl.relays)) + for _, r := range cl.relays { + relays = append(relays, r) + } + cl.relaysMu.Unlock() + for _, r := range relays { + r.close() + } +} + +// pipeFrames copies frames from src to dst opaquely, preserving opcode and +// payload bytes. It reports the first error on errCh. +func pipeFrames(src, dst *ws.Conn, errCh chan<- error) { + for { + msgType, payload, err := src.ReadMessage() + if err != nil { + errCh <- err + return + } + if err := dst.WriteMessage(msgType, payload); err != nil { + errCh <- err + return + } + } +} + +// idleTimeout runs the idle watchdog: it exits (via the returned channel) +// after cfg.IdleTimeout with no active relays and no activity. Active relays +// and relay activity suppress the timer. +func (cl *controlLoop) idleTimeout(ctx context.Context) <-chan struct{} { + done := make(chan struct{}) + go func() { + timer := time.NewTimer(cl.cfg.IdleTimeout) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-cl.activityCh: + // Relay activity resets the idle timer. + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(cl.cfg.IdleTimeout) + case <-timer.C: + if cl.hasActiveRelays() { + // A relay is active; suppress the timeout and re-arm. + timer.Reset(cl.cfg.IdleTimeout) + continue + } + log.Info("idle_timeout_exit", map[string]interface{}{ + "idle_timeout_ms": cl.cfg.IdleTimeout.Milliseconds(), + }) + close(done) + return + } + } + }() + return done +} + +// errControlLoop is a sentinel for control-loop shutdown. +var errControlLoop = errors.New("control loop stopped") + +// errTerminated is returned by controlLoop.run when the apiserver sends WS +// close code 4001 (closeCodeAgentTerminated), signalling a deliberate "End +// Session" termination. The job-agent must shut down instead of reconnecting. +var errTerminated = errors.New("agent terminated by server") + +// closeCodeAgentTerminated must match the constant in the apiserver's +// agent_registry.go. Using 4001 (application-defined range 4000–4999). +const closeCodeAgentTerminated = 4001 + diff --git a/remote-bob/job-agent/internal/tunnel/control_test.go b/remote-bob/job-agent/internal/tunnel/control_test.go new file mode 100644 index 00000000..85c9ab78 --- /dev/null +++ b/remote-bob/job-agent/internal/tunnel/control_test.go @@ -0,0 +1,633 @@ +package tunnel + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/CodeEngine/remote-bob-job-agent/internal/ws" +) + +// testControlServer is a fake apiserver control endpoint for tests. It +// records the Authorization header, the agent query param, and control +// messages, and can send relay-open/relay-close messages. It also serves +// /ws/relay so the agent's relay dial lands on the same host as the control +// connection (mirroring the real apiserver). +type testControlServer struct { + server *httptest.Server + mu sync.Mutex + authHeader string + agentParam string + registers []registerMessage + conns []*ws.Conn + connMu sync.Mutex + onConnect func(conn *ws.Conn) + reject bool + rejectCount int + relay *testRelayServer +} + +func newTestControlServer() *testControlServer { + ts := &testControlServer{} + mux := http.NewServeMux() + mux.HandleFunc("/ws/agent", func(w http.ResponseWriter, r *http.Request) { + ts.mu.Lock() + ts.authHeader = r.Header.Get("Authorization") + ts.agentParam = r.URL.Query().Get("agent") + reject := ts.reject + ts.mu.Unlock() + if reject { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + conn, err := ws.Upgrade(w, r) + if err != nil { + return + } + ts.connMu.Lock() + ts.conns = append(ts.conns, conn) + ts.connMu.Unlock() + if ts.onConnect != nil { + ts.onConnect(conn) + } + // Read control messages until the connection closes. + for { + _, msg, err := conn.ReadMessage() + if err != nil { + return + } + var reg registerMessage + if err := json.Unmarshal(msg, ®); err == nil && reg.Type == controlRegister { + ts.mu.Lock() + ts.registers = append(ts.registers, reg) + ts.mu.Unlock() + } + } + }) + // The relay endpoint shares the control server's host so the agent's + // relay dial (derived from GATEWAY_WSS) reaches it. + ts.relay = &testRelayServer{} + mux.HandleFunc("/ws/relay", ts.relay.handle) + ts.server = httptest.NewServer(mux) + return ts +} + +func (ts *testControlServer) close() { + ts.connMu.Lock() + for _, c := range ts.conns { + _ = c.Close() + } + ts.connMu.Unlock() + ts.relay.close() + ts.server.Close() +} + +func (ts *testControlServer) sendRelayOpen(conn *ws.Conn, relayID, service, token string) error { + return conn.WriteJSON(map[string]interface{}{ + "type": "relay-open", + "relay_id": relayID, + "service": service, + "relay_token": token, + }) +} + +func (ts *testControlServer) sendRelayClose(conn *ws.Conn, relayID string) error { + return conn.WriteJSON(map[string]interface{}{ + "type": "relay-close", + "relay_id": relayID, + }) +} + +func (ts *testControlServer) currentConn() *ws.Conn { + ts.connMu.Lock() + defer ts.connMu.Unlock() + if len(ts.conns) == 0 { + return nil + } + return ts.conns[len(ts.conns)-1] +} + +func (ts *testControlServer) registerCount() int { + ts.mu.Lock() + defer ts.mu.Unlock() + return len(ts.registers) +} + +func (ts *testControlServer) lastRegister() *registerMessage { + ts.mu.Lock() + defer ts.mu.Unlock() + if len(ts.registers) == 0 { + return nil + } + return &ts.registers[len(ts.registers)-1] +} + +func testConfigForServer(t *testing.T, wsURL string) *Config { + t.Helper() + // httptest servers are http://; convert to ws:// for the gateway. + wsBase := "ws" + strings.TrimPrefix(wsURL, "http") + gateway := strings.TrimSuffix(wsBase, "/") + "/ws" + return &Config{ + AgentID: "agent-1", + RunToken: "run-token-1", + GatewayWSS: gateway, + TTYDPort: "7080", + HealthPort: "7081", + Workspace: "/workspace", + BobMode: "interactive", + BobShellAPIKey: "bob-key", + IdleTimeout: 5 * time.Minute, + ReconnectDelay: 50 * time.Millisecond, + TTYDReadyTimeout: 2 * time.Second, + TmuxDeathGrace: 5 * time.Second, + } +} + +// TestControlDialWithRunToken verifies the control dial carries the run token +// in the Authorization header (never in the URL) and registers the ttyd +// service. +func TestControlDialWithRunToken(t *testing.T) { + ts := newTestControlServer() + defer ts.close() + + cfg := testConfigForServer(t, ts.server.URL) + cl := newControlLoop(cfg, newTTYDAdapter("ws://127.0.0.1:7080")) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- cl.run(ctx) }() + + // Wait for registration. + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if ts.registerCount() > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + if ts.registerCount() == 0 { + t.Fatal("agent never registered") + } + + ts.mu.Lock() + auth := ts.authHeader + agent := ts.agentParam + ts.mu.Unlock() + if auth != "Bearer run-token-1" { + t.Errorf("Authorization header = %q, want Bearer run-token-1", auth) + } + if agent != "agent-1" { + t.Errorf("agent query param = %q, want agent-1", agent) + } + reg := ts.lastRegister() + if reg == nil || len(reg.Services) != 1 { + t.Fatalf("register services = %+v, want exactly one service", reg) + } + if reg.Services[0].Name != "ttyd" || reg.Services[0].Upstream != "ws://127.0.0.1:7080" { + t.Errorf("registered service = %+v, want ttyd at ws://127.0.0.1:7080", reg.Services[0]) + } +} + +// TestControlReconnectWithBackoff verifies that control loss triggers a +// re-dial with backoff and re-registration without restarting tmux/ttyd. +func TestControlReconnectWithBackoff(t *testing.T) { + ts := newTestControlServer() + defer ts.close() + + cfg := testConfigForServer(t, ts.server.URL) + cfg.ReconnectDelay = 30 * time.Millisecond + cl := newControlLoop(cfg, newTTYDAdapter("ws://127.0.0.1:7080")) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- cl.run(ctx) }() + + // Wait for the first registration. + waitForRegistrations(t, ts, 1) + + // Drop the first control connection; the loop should re-dial and + // re-register. + first := ts.currentConn() + if first != nil { + _ = first.Close() + } + waitForRegistrations(t, ts, 2) + + // The re-registration must carry the same service set. + reg := ts.lastRegister() + if reg == nil || len(reg.Services) != 1 || reg.Services[0].Name != "ttyd" { + t.Errorf("re-register = %+v, want ttyd service", reg) + } +} + +// TestControlRejectedRedialRetries verifies a rejected re-dial (invalid run +// token) is retried with backoff, not a crash or spin. +func TestControlRejectedRedialRetries(t *testing.T) { + ts := newTestControlServer() + defer ts.close() + + cfg := testConfigForServer(t, ts.server.URL) + cfg.ReconnectDelay = 20 * time.Millisecond + cl := newControlLoop(cfg, newTTYDAdapter("ws://127.0.0.1:7080")) + + // Reject the first dial, then accept. + ts.mu.Lock() + ts.reject = true + ts.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- cl.run(ctx) }() + + // Let the rejected dial happen, then allow connections. + time.Sleep(100 * time.Millisecond) + ts.mu.Lock() + ts.reject = false + ts.mu.Unlock() + + // The agent should eventually connect and register. + waitForRegistrations(t, ts, 1) +} + +// TestRelayOpenHandshakeAndPipe verifies the full relay flow: relay-open → +// upstream dial with ttyd handshake → first browser frame forwarded → opaque +// pipe. +func TestRelayOpenHandshakeAndPipe(t *testing.T) { + // Fake ttyd upstream that records the handshake and echoes frames. + upstream := newTestTTYDUpstream(t) + defer upstream.close() + + ts := newTestControlServer() + defer ts.close() + + cfg := testConfigForServer(t, ts.server.URL) + cl := newControlLoop(cfg, newTTYDAdapter(upstream.wsURL)) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- cl.run(ctx) }() + waitForRegistrations(t, ts, 1) + + // Fake relay endpoint that accepts the agent's relay dial and pipes. + relay := ts.relay + + // Send relay-open; the agent dials the relay and the upstream. + conn := ts.currentConn() + if conn == nil { + t.Fatal("no control connection") + } + if err := ts.sendRelayOpen(conn, "relay-1", "ttyd", "relay-token-1"); err != nil { + t.Fatal(err) + } + + // Wait for the upstream to receive the handshake. + upstream.waitForHandshake(t) + relay.waitForConn(t, 1) + + // The relay server sends the first browser frame (a resize frame). + relay.sendFirstFrame([]byte{'1', '{', '"', 'c', 'o', 'l', 'u', 'm', 'n', 's', '"', ':', '1', '2', '0', ',', '"', 'r', 'o', 'w', 's', '"', ':', '3', '0', '}'}) + + // The upstream must receive the first frame (resize forwarded). + upstream.waitForFrame(t, []byte{'1', '{', '"', 'c', 'o', 'l', 'u', 'm', 'n', 's', '"', ':', '1', '2', '0', ',', '"', 'r', 'o', 'w', 's', '"', ':', '3', '0', '}'}) + + // Opaque pipe: relay → upstream and upstream → relay. + relay.sendFrame([]byte("hello from browser")) + upstream.waitForFrame(t, []byte("hello from browser")) + + upstream.sendFrame([]byte("output from ttyd")) + relay.waitForFrame(t, []byte("output from ttyd")) +} + +// TestRelayCloseClosesUpstream verifies relay-close closes the upstream. +func TestRelayCloseClosesUpstream(t *testing.T) { + upstream := newTestTTYDUpstream(t) + defer upstream.close() + + ts := newTestControlServer() + defer ts.close() + + cfg := testConfigForServer(t, ts.server.URL) + cl := newControlLoop(cfg, newTTYDAdapter(upstream.wsURL)) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- cl.run(ctx) }() + waitForRegistrations(t, ts, 1) + + conn := ts.currentConn() + if err := ts.sendRelayOpen(conn, "relay-2", "ttyd", "relay-token-2"); err != nil { + t.Fatal(err) + } + upstream.waitForHandshake(t) + + // relay-close should close the upstream connection. + if err := ts.sendRelayClose(conn, "relay-2"); err != nil { + t.Fatal(err) + } + upstream.waitForClose(t) +} + +// TestRelayOpenUnreachableUpstreamFailsCleanly verifies a relay-open for an +// unreachable upstream fails cleanly and the agent stays registered. +func TestRelayOpenUnreachableUpstreamFailsCleanly(t *testing.T) { + ts := newTestControlServer() + defer ts.close() + + cfg := testConfigForServer(t, ts.server.URL) + // Upstream points at a closed port. + cl := newControlLoop(cfg, newTTYDAdapter("ws://127.0.0.1:1")) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- cl.run(ctx) }() + waitForRegistrations(t, ts, 1) + + conn := ts.currentConn() + if err := ts.sendRelayOpen(conn, "relay-3", "ttyd", "relay-token-3"); err != nil { + t.Fatal(err) + } + + // The agent must stay registered (control connection alive). + time.Sleep(200 * time.Millisecond) + if ts.registerCount() != 1 { + t.Errorf("agent unregistered after failed relay-open: %d registrations", ts.registerCount()) + } + // Control connection still usable: a second relay-open is accepted. + if err := ts.sendRelayOpen(conn, "relay-4", "ttyd", "relay-token-4"); err != nil { + t.Errorf("control connection unusable after failed relay-open: %v", err) + } +} + +// TestRelayOpenUnknownServiceIgnored verifies relay-open for an unregistered +// service is ignored cleanly. +func TestRelayOpenUnknownServiceIgnored(t *testing.T) { + ts := newTestControlServer() + defer ts.close() + + cfg := testConfigForServer(t, ts.server.URL) + cl := newControlLoop(cfg, newTTYDAdapter("ws://127.0.0.1:7080")) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- cl.run(ctx) }() + waitForRegistrations(t, ts, 1) + + conn := ts.currentConn() + if err := ts.sendRelayOpen(conn, "relay-5", "doesnotexist", "relay-token-5"); err != nil { + t.Fatal(err) + } + time.Sleep(100 * time.Millisecond) + if ts.registerCount() != 1 { + t.Errorf("agent unregistered after unknown-service relay-open") + } +} + +func waitForRegistrations(t *testing.T, ts *testControlServer, n int) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if ts.registerCount() >= n { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("expected %d registrations, got %d", n, ts.registerCount()) +} + +// testTTYDUpstream is a fake ttyd server that records the handshake and +// echoes frames. +type testTTYDUpstream struct { + server *httptest.Server + wsURL string + mu sync.Mutex + writeMu sync.Mutex + handshake []byte + frames [][]byte + closed chan struct{} + conn *ws.Conn +} + +func newTestTTYDUpstream(t *testing.T) *testTTYDUpstream { + t.Helper() + u := &testTTYDUpstream{closed: make(chan struct{})} + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + conn, err := ws.Upgrade(w, r) + if err != nil { + return + } + u.mu.Lock() + u.conn = conn + u.mu.Unlock() + defer func() { + select { + case <-u.closed: + default: + close(u.closed) + } + }() + for { + _, payload, err := conn.ReadMessage() + if err != nil { + return + } + u.mu.Lock() + if len(u.handshake) == 0 { + u.handshake = append([]byte(nil), payload...) + } else { + u.frames = append(u.frames, append([]byte(nil), payload...)) + } + u.mu.Unlock() + // Echo the frame back so the relay round-trips. + u.writeMu.Lock() + _ = conn.WriteFrame(ws.MsgBinary, payload) + u.writeMu.Unlock() + } + }) + u.server = httptest.NewServer(mux) + u.wsURL = "ws" + strings.TrimPrefix(u.server.URL, "http") + return u +} + +func (u *testTTYDUpstream) close() { + u.mu.Lock() + if u.conn != nil { + _ = u.conn.Close() + } + u.mu.Unlock() + u.server.Close() +} + +func (u *testTTYDUpstream) waitForHandshake(t *testing.T) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + u.mu.Lock() + hs := len(u.handshake) > 0 + u.mu.Unlock() + if hs { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("upstream never received the ttyd handshake") +} + +func (u *testTTYDUpstream) waitForFrame(t *testing.T, want []byte) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + u.mu.Lock() + var found bool + for _, f := range u.frames { + if string(f) == string(want) { + found = true + break + } + } + u.mu.Unlock() + if found { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("upstream never received frame %q (got %q)", want, u.allFrames()) +} + +func (u *testTTYDUpstream) allFrames() [][]byte { + u.mu.Lock() + defer u.mu.Unlock() + out := make([][]byte, len(u.frames)) + copy(out, u.frames) + return out +} + +func (u *testTTYDUpstream) sendFrame(payload []byte) { + u.mu.Lock() + conn := u.conn + u.mu.Unlock() + if conn != nil { + u.writeMu.Lock() + _ = conn.WriteFrame(ws.MsgBinary, payload) + u.writeMu.Unlock() + } +} + +func (u *testTTYDUpstream) waitForClose(t *testing.T) { + t.Helper() + select { + case <-u.closed: + case <-time.After(5 * time.Second): + t.Fatal("upstream connection was not closed after relay-close") + } +} + +// testRelayServer is a fake /ws/relay endpoint that accepts the agent's relay +// dials and lets the test send/receive frames. It is mounted on the control +// server's mux so the agent's relay dial (derived from GATEWAY_WSS) reaches +// it. It tracks multiple concurrent relay connections (fan-out). +type testRelayServer struct { + mu sync.Mutex + conns []*ws.Conn + frames [][]byte +} + +func (r *testRelayServer) handle(w http.ResponseWriter, req *http.Request) { + conn, err := ws.Upgrade(w, req) + if err != nil { + return + } + r.mu.Lock() + r.conns = append(r.conns, conn) + r.mu.Unlock() + for { + _, payload, err := conn.ReadMessage() + if err != nil { + return + } + r.mu.Lock() + r.frames = append(r.frames, append([]byte(nil), payload...)) + r.mu.Unlock() + } +} + +func (r *testRelayServer) close() { + r.mu.Lock() + for _, c := range r.conns { + _ = c.Close() + } + r.mu.Unlock() +} + +// waitForConn blocks until at least n agent relay connections are live. +func (r *testRelayServer) waitForConn(t *testing.T, n int) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + r.mu.Lock() + count := len(r.conns) + r.mu.Unlock() + if count >= n { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("expected %d relay dials, got %d", n, len(r.conns)) +} + +// sendFirstFrame writes a frame on the first live relay connection. +func (r *testRelayServer) sendFirstFrame(payload []byte) { + r.mu.Lock() + defer r.mu.Unlock() + if len(r.conns) > 0 { + _ = r.conns[0].WriteFrame(ws.MsgBinary, payload) + } +} + +// sendFrameOn writes a frame on the i-th live relay connection. +func (r *testRelayServer) sendFrameOn(i int, payload []byte) { + r.mu.Lock() + defer r.mu.Unlock() + if i < len(r.conns) { + _ = r.conns[i].WriteFrame(ws.MsgBinary, payload) + } +} + +func (r *testRelayServer) sendFrame(payload []byte) { + r.sendFirstFrame(payload) +} + +func (r *testRelayServer) waitForFrame(t *testing.T, want []byte) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + r.mu.Lock() + var found bool + for _, f := range r.frames { + if string(f) == string(want) { + found = true + break + } + } + r.mu.Unlock() + if found { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("relay never received frame %q", want) +} + +var _ = sync.Mutex{} diff --git a/remote-bob/job-agent/internal/tunnel/git.go b/remote-bob/job-agent/internal/tunnel/git.go new file mode 100644 index 00000000..e06da432 --- /dev/null +++ b/remote-bob/job-agent/internal/tunnel/git.go @@ -0,0 +1,31 @@ +package tunnel + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// prepareWorkspace creates the workspace directory. +func prepareWorkspace(cfg *Config) error { + return os.MkdirAll(cfg.Workspace, 0o755) +} + +func ensureBinary(name string) error { + _, err := exec.LookPath(name) + if err != nil { + return fmt.Errorf("%s not found", name) + } + return nil +} + +func runCommand(name string, args []string, stdin string) error { + cmd := exec.Command(name, args...) + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/remote-bob/job-agent/internal/tunnel/health.go b/remote-bob/job-agent/internal/tunnel/health.go new file mode 100644 index 00000000..60cd8e3f --- /dev/null +++ b/remote-bob/job-agent/internal/tunnel/health.go @@ -0,0 +1,68 @@ +package tunnel + +import ( + "context" + "encoding/json" + "net/http" + "sync" + "time" + + "github.com/CodeEngine/remote-bob-job-agent/internal/log" +) + +// healthState tracks tmux readiness for the health endpoint. +type healthState struct { + mu sync.RWMutex + tmuxReady bool +} + +func (h *healthState) setTmuxReady(ready bool) { + h.mu.Lock() + defer h.mu.Unlock() + h.tmuxReady = ready +} + +func (h *healthState) isTmuxReady() bool { + h.mu.RLock() + defer h.mu.RUnlock() + return h.tmuxReady +} + +// healthHandler returns the health response for the current tmux readiness: +// 200 {"status":"healthy"} when tmux is ready, 503 {"status":"unhealthy"} +// otherwise. It is a pure function of the health state so tests can assert +// the 503 transition without a live server. +func healthHandler(cfg *Config, state *healthState, w http.ResponseWriter) { + status := http.StatusOK + body := map[string]string{"status": "healthy", "agent": cfg.AgentID} + if !state.isTmuxReady() { + status = http.StatusServiceUnavailable + body["status"] = "unhealthy" + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +// serveHealth runs the health server bound to 127.0.0.1:HEALTH_PORT. It +// returns 200 {"status":"healthy"} only when tmux is ready, and 503 +// {"status":"unhealthy"} otherwise. +func serveHealth(ctx context.Context, cfg *Config, state *healthState) error { + mux := http.NewServeMux() + handleHealth := func(w http.ResponseWriter, r *http.Request) { + healthHandler(cfg, state, w) + } + mux.HandleFunc("/", handleHealth) + mux.HandleFunc("/health", handleHealth) + server := &http.Server{Addr: "127.0.0.1:" + cfg.HealthPort, Handler: mux} + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + }() + log.Info("health_server_started", map[string]interface{}{ + "addr": "127.0.0.1:" + cfg.HealthPort, + }) + return server.ListenAndServe() +} diff --git a/remote-bob/job-agent/internal/tunnel/runtime.go b/remote-bob/job-agent/internal/tunnel/runtime.go new file mode 100644 index 00000000..ffcd507d --- /dev/null +++ b/remote-bob/job-agent/internal/tunnel/runtime.go @@ -0,0 +1,371 @@ +package tunnel + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "sync" + "syscall" + "time" + + "github.com/CodeEngine/remote-bob-job-agent/internal/log" +) + +// NewRuntime builds the runtime from a loaded config. +func NewRuntime(cfg *Config) *Runtime { + adapter := newTTYDAdapter("ws://127.0.0.1:" + cfg.TTYDPort) + return &Runtime{ + cfg: cfg, + state: &healthState{}, + control: newControlLoop(cfg, adapter), + shutdownCh: make(chan struct{}), + tmuxPollInterval: 5 * time.Second, + } +} + +// Runtime supervises the tmux session, ttyd process, health server, git +// integration, and the control loop. It owns the startup order +// (tmux → ttyd → control dial) and the graceful shutdown path. +type Runtime struct { + cfg *Config + state *healthState + control *controlLoop + ttydCmd *exec.Cmd + ttydMu sync.Mutex + closeOnce sync.Once + shutdownCh chan struct{} + shutdownErr error + // tmuxPollInterval is how often the tmux monitor checks the session. + // It is a field so tests can shorten it. + tmuxPollInterval time.Duration +} + +// Run starts everything and blocks until shutdown. It returns nil for a +// graceful exit (idle timeout, SIGTERM, tmux death) and an error for a +// startup failure. +func (rt *Runtime) Run(ctx context.Context) error { + log.Info("job_agent_starting", map[string]interface{}{ + "agent_id": rt.cfg.AgentID, + "ttyd_port": rt.cfg.TTYDPort, + "health_port": rt.cfg.HealthPort, + "workspace": rt.cfg.Workspace, + "bob_mode": rt.cfg.BobMode, + "idle_timeout": rt.cfg.IdleTimeout.String(), + }) + + if err := ensureBinary("bob"); err != nil { + return err + } + if err := ensureBinary("tmux"); err != nil { + return err + } + if err := ensureBinary("ttyd"); err != nil { + return err + } + logVersion("bob", "--version") + + if err := patchBobSettings(rt.cfg); err != nil { + // Non-fatal: log and continue; baked-in defaults are acceptable. + log.Warn("bob_settings_patch_failed", map[string]interface{}{ + "error": err.Error(), + }) + } + + if err := prepareWorkspace(rt.cfg); err != nil { + return err + } + if err := createTmuxSession(rt.cfg); err != nil { + return err + } + rt.state.setTmuxReady(true) + log.Info("tmux_ready", map[string]interface{}{ + "agent_id": rt.cfg.AgentID, + }) + + childCtx, cancel := context.WithCancel(ctx) + defer cancel() + + errCh := make(chan error, 4) + var wg sync.WaitGroup + + // Health server (127.0.0.1:7081). + wg.Add(1) + go func() { + defer wg.Done() + if err := serveHealth(childCtx, rt.cfg, rt.state); err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, http.ErrServerClosed) { + select { + case errCh <- err: + default: + } + } + }() + + // tmux monitor: if the session dies, mark health unhealthy and keep the + // health server alive (serving 503) for the bounded grace period so the + // unhealthy window is observable to liveness probes, then shut down + // gracefully (finalizing git). The health server is bound to childCtx, + // so it keeps serving 503 until the grace period elapses. + tmuxDied := rt.monitorTmux(childCtx) + wg.Add(1) + go func() { + defer wg.Done() + select { + case <-tmuxDied: + select { + case <-time.After(rt.cfg.TmuxDeathGrace): + case <-childCtx.Done(): + } + cancel() + case <-childCtx.Done(): + } + }() + + // Start ttyd (127.0.0.1:7080) after tmux is up. + if err := rt.startTTYD(); err != nil { + cancel() + wg.Wait() + return err + } + defer rt.stopTTYD() + + // Wait for ttyd to accept connections before dialing the control + // connection (startup order tmux → ttyd → control dial). + if err := waitForTTYD(childCtx, "ws://127.0.0.1:"+rt.cfg.TTYDPort, rt.cfg.TTYDReadyTimeout); err != nil { + cancel() + wg.Wait() + return err + } + log.Info("ttyd_ready", map[string]interface{}{ + "addr": "127.0.0.1:" + rt.cfg.TTYDPort, + }) + + // Control loop: dial, register, handle relays, reconnect with backoff. + wg.Add(1) + go func() { + defer wg.Done() + err := rt.control.run(childCtx) + if errors.Is(err, errTerminated) { + // The apiserver deliberately terminated this agent (End Session / + // DELETE /agents/{id}). Trigger graceful shutdown; this is not an + // error condition. + log.Info("agent_terminated_by_server_shutdown", map[string]interface{}{ + "agent_id": rt.cfg.AgentID, + }) + cancel() + return + } + if err != nil && !errors.Is(err, context.Canceled) { + select { + case errCh <- err: + default: + } + } + }() + + // Idle timeout: exit gracefully when no active relays for the timeout. + idleDone := rt.control.idleTimeout(childCtx) + + select { + case <-childCtx.Done(): + // SIGTERM/SIGINT, tmux death, or a fatal error. + case <-idleDone: + log.Info("idle_timeout_shutdown", map[string]interface{}{ + "agent_id": rt.cfg.AgentID, + }) + case err := <-errCh: + rt.shutdownErr = err + cancel() + } + + // Graceful shutdown: close relays, finalize git, stop ttyd/tmux. + rt.shutdown() + wg.Wait() + return rt.shutdownErr +} + +// shutdown performs the graceful shutdown sequence exactly once. +func (rt *Runtime) shutdown() { + rt.closeOnce.Do(func() { + log.Info("graceful_shutdown_start", map[string]interface{}{ + "agent_id": rt.cfg.AgentID, + }) + // Close active relays so browsers see a clean close. + rt.control.closeAllRelays() + rt.stopTTYD() + killTmuxSession(rt.cfg.AgentID) + close(rt.shutdownCh) + log.Info("graceful_shutdown_complete", map[string]interface{}{ + "agent_id": rt.cfg.AgentID, + }) + }) +} + +// patchBobSettings applies runtime environment overrides to the Bob Shell +// settings file. It merges BOB_APPROVAL_MODE and BOB_TELEMETRY_ENABLED into +// the existing settings.json (if present) before Bob is started. Baked-in +// image defaults are used if the env vars are not set or the file is absent. +func patchBobSettings(cfg *Config) error { + if cfg.BobApprovalMode == "" && !cfg.BobTelemetrySet { + return nil // nothing to override + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("could not determine home directory: %w", err) + } + settingsPath := filepath.Join(homeDir, ".bob", "settings", "settings.json") + + // Read existing settings; start with empty map if file is absent. + raw := map[string]interface{}{} + if data, err := os.ReadFile(settingsPath); err == nil { + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("parse %s: %w", settingsPath, err) + } + } + + if cfg.BobApprovalMode != "" { + raw["approvalMode"] = cfg.BobApprovalMode + } + if cfg.BobTelemetrySet { + if t, ok := raw["telemetry"].(map[string]interface{}); ok { + t["enabled"] = cfg.BobTelemetryEnabled + } else { + raw["telemetry"] = map[string]interface{}{"enabled": cfg.BobTelemetryEnabled} + } + } + + data, err := json.Marshal(raw) + if err != nil { + return fmt.Errorf("marshal settings: %w", err) + } + if err := os.WriteFile(settingsPath, data, 0o644); err != nil { + return fmt.Errorf("write %s: %w", settingsPath, err) + } + log.Info("bob_settings_patched", map[string]interface{}{ + "approval_mode": cfg.BobApprovalMode, + "telemetry_set": cfg.BobTelemetrySet, + "telemetry_enabled": cfg.BobTelemetryEnabled, + }) + return nil +} + +// tmuxSocket returns the explicit tmux server socket path for this agent. +// Using a fixed path prevents socket-divergence when tmux is created by one +// process and attached by ttyd (a child that may inherit a different TMPDIR). +func tmuxSocket(agentID string) string { + return "/tmp/tmux-bob-" + agentID +} + +// createTmuxSession starts a detached tmux session running the BOB_MODE +// variant of `bob chat --auto-approve --trust --accept-license`. +func createTmuxSession(cfg *Config) error { + command := bobCommand(cfg) + socket := tmuxSocket(cfg.AgentID) + if err := runCommand("tmux", []string{ + "-S", socket, + "new-session", "-d", "-s", cfg.AgentID, + "-c", cfg.Workspace, + "-e", "TERM=xterm-256color", + "-e", "LANG=" + cfg.Lang, + "-e", "LC_ALL=" + cfg.LCAll, + "-e", "BOBSHELL_API_KEY=" + cfg.BobShellAPIKey, + "bash", "-lc", command, + }, ""); err != nil { + return err + } + time.Sleep(3 * time.Second) + if err := runCommand("tmux", []string{"-S", socket, "has-session", "-t", cfg.AgentID}, ""); err != nil { + return fmt.Errorf("failed to create tmux session: %w", err) + } + return nil +} + +// startTTYD launches ttyd bound to 127.0.0.1 only. +// SysProcAttr.Setsid gives ttyd its own process session so tcsetpgrp() +// works correctly when tmux attach-session is the PTY child. +func (rt *Runtime) startTTYD() error { + socket := tmuxSocket(rt.cfg.AgentID) + cmd := exec.Command("ttyd", "--port", rt.cfg.TTYDPort, "--interface", "127.0.0.1", "--writable", "--max-clients", "10", "tmux", "-S", socket, "attach-session", "-t", rt.cfg.AgentID) + cmd.Env = append(os.Environ(), "TMUX_TMPDIR=/tmp") + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return err + } + rt.ttydMu.Lock() + rt.ttydCmd = cmd + rt.ttydMu.Unlock() + go func() { + _ = cmd.Wait() + }() + log.Info("ttyd_started", map[string]interface{}{ + "addr": "127.0.0.1:" + rt.cfg.TTYDPort, + }) + return nil +} + +// stopTTYD terminates the ttyd process. +func (rt *Runtime) stopTTYD() { + rt.ttydMu.Lock() + cmd := rt.ttydCmd + rt.ttydMu.Unlock() + if cmd != nil && cmd.Process != nil { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + } +} + +// monitorTmux polls the tmux session; when it dies, health flips to +// unhealthy and the returned channel is closed. The caller decides when to +// proceed with shutdown (after the bounded grace period), so the health +// server keeps serving 503 during that window. +func (rt *Runtime) monitorTmux(ctx context.Context) <-chan struct{} { + died := make(chan struct{}) + go func() { + ticker := time.NewTicker(rt.tmuxPollInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := exec.Command("tmux", "-S", tmuxSocket(rt.cfg.AgentID), "has-session", "-t", rt.cfg.AgentID).Run(); err != nil { + rt.state.setTmuxReady(false) + log.Info("tmux_session_died", map[string]interface{}{ + "agent_id": rt.cfg.AgentID, + }) + close(died) + return + } + } + } + }() + return died +} + +// killTmuxSession kills the tmux session (best effort). +func killTmuxSession(session string) { + _ = exec.Command("tmux", "-S", tmuxSocket(session), "kill-session", "-t", session).Run() +} + +func logVersion(name string, args ...string) { + cmd := exec.Command(name, args...) + output, err := cmd.CombinedOutput() + if err != nil { + log.Warn("binary_version_failed", map[string]interface{}{ + "name": name, + "error": err.Error(), + }) + return + } + log.Info("binary_version", map[string]interface{}{ + "name": name, + "version": string(output), + }) +} diff --git a/remote-bob/job-agent/internal/tunnel/runtime_patch_test.go b/remote-bob/job-agent/internal/tunnel/runtime_patch_test.go new file mode 100644 index 00000000..ae274cd3 --- /dev/null +++ b/remote-bob/job-agent/internal/tunnel/runtime_patch_test.go @@ -0,0 +1,166 @@ +package tunnel + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestPatchBobSettings_NothingToOverride(t *testing.T) { + // When both BobApprovalMode is empty and BobTelemetrySet is false, + // patchBobSettings should be a no-op (no file written). + cfg := &Config{} + tmp := t.TempDir() + settingsPath := filepath.Join(tmp, ".bob", "settings", "settings.json") + + // Override home directory — patchBobSettings uses os.UserHomeDir(). + // We can't easily override that, so verify the function returns nil + // without creating any file. + if err := patchBobSettings(cfg); err != nil { + t.Fatalf("expected no-op, got error: %v", err) + } + // The real home dir may or may not have a settings file; just check + // the no-op path returns without error. + _ = settingsPath +} + +func TestPatchBobSettings_ApprovalModeAndTelemetry(t *testing.T) { + // Write a minimal settings.json, then patch it. + tmp := t.TempDir() + dir := filepath.Join(tmp, ".bob", "settings") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + settingsFile := filepath.Join(dir, "settings.json") + initial := map[string]interface{}{ + "approvalMode": "manual", + "telemetry": map[string]interface{}{"enabled": true}, + "other": "preserved", + } + initialData, _ := json.Marshal(initial) + if err := os.WriteFile(settingsFile, initialData, 0o644); err != nil { + t.Fatal(err) + } + + // Point patchBobSettings at our temp dir by overriding HOME. + origHome := os.Getenv("HOME") + t.Cleanup(func() { os.Setenv("HOME", origHome) }) + os.Setenv("HOME", tmp) + + cfg := &Config{ + BobApprovalMode: "auto_approve", + BobTelemetrySet: true, + BobTelemetryEnabled: false, + } + if err := patchBobSettings(cfg); err != nil { + t.Fatalf("patchBobSettings: %v", err) + } + + data, err := os.ReadFile(settingsFile) + if err != nil { + t.Fatal(err) + } + var got map[string]interface{} + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("parse patched settings: %v", err) + } + + if got["approvalMode"] != "auto_approve" { + t.Errorf("approvalMode = %v, want auto_approve", got["approvalMode"]) + } + tel, ok := got["telemetry"].(map[string]interface{}) + if !ok { + t.Fatalf("telemetry not a map: %v", got["telemetry"]) + } + if tel["enabled"] != false { + t.Errorf("telemetry.enabled = %v, want false", tel["enabled"]) + } + // Existing keys must be preserved. + if got["other"] != "preserved" { + t.Errorf("other field dropped: %v", got["other"]) + } +} + +func TestPatchBobSettings_MissingFile(t *testing.T) { + // If settings.json does not exist, patchBobSettings should create it. + tmp := t.TempDir() + dir := filepath.Join(tmp, ".bob", "settings") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + + origHome := os.Getenv("HOME") + t.Cleanup(func() { os.Setenv("HOME", origHome) }) + os.Setenv("HOME", tmp) + + cfg := &Config{ + BobApprovalMode: "auto_approve", + } + if err := patchBobSettings(cfg); err != nil { + t.Fatalf("patchBobSettings with missing file: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "settings.json")) + if err != nil { + t.Fatal(err) + } + var got map[string]interface{} + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got["approvalMode"] != "auto_approve" { + t.Errorf("approvalMode = %v, want auto_approve", got["approvalMode"]) + } +} + +func TestLoadConfig_BobApprovalModeAndTelemetry(t *testing.T) { + // Minimal required env to make LoadConfig succeed. + for k, v := range map[string]string{ + "AGENT_ID": "test-agent", + "RUN_TOKEN": "test-token", + "GATEWAY_WSS": "wss://api.example.com", + "BOBSHELL_API_KEY": "test-api-key", + "BOB_APPROVAL_MODE": "manual", + "BOB_TELEMETRY_ENABLED": "false", + } { + t.Setenv(k, v) + } + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.BobApprovalMode != "manual" { + t.Errorf("BobApprovalMode = %q, want manual", cfg.BobApprovalMode) + } + if !cfg.BobTelemetrySet { + t.Error("BobTelemetrySet should be true when BOB_TELEMETRY_ENABLED is set") + } + if cfg.BobTelemetryEnabled { + t.Error("BobTelemetryEnabled should be false for 'false'") + } +} + +func TestLoadConfig_BobTelemetryTrueValues(t *testing.T) { + for _, val := range []string{"true", "1"} { + t.Run(val, func(t *testing.T) { + for k, v := range map[string]string{ + "AGENT_ID": "test-agent", + "RUN_TOKEN": "test-token", + "GATEWAY_WSS": "wss://api.example.com", + "BOBSHELL_API_KEY": "test-api-key", + "BOB_TELEMETRY_ENABLED": val, + } { + t.Setenv(k, v) + } + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if !cfg.BobTelemetryEnabled { + t.Errorf("BobTelemetryEnabled should be true for %q", val) + } + }) + } +} diff --git a/remote-bob/job-agent/internal/tunnel/ttyd_adapter.go b/remote-bob/job-agent/internal/tunnel/ttyd_adapter.go new file mode 100644 index 00000000..3389b330 --- /dev/null +++ b/remote-bob/job-agent/internal/tunnel/ttyd_adapter.go @@ -0,0 +1,126 @@ +package tunnel + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/CodeEngine/remote-bob-job-agent/internal/log" + "github.com/CodeEngine/remote-bob-job-agent/internal/ws" +) + +// ttyd 1.7.7 binary protocol opcodes (ASCII prefix bytes on binary frames). +const ( + ttydOpcodeOutput = '0' // 0x30 — terminal output (ttyd → client) + ttydOpcodeInput = '0' // 0x30 — keyboard input (client → ttyd) + ttydOpcodeResize = '1' // 0x31 — resize JSON {columns,rows} (client → ttyd) +) + +// ttydHandshake is the ttyd 1.7.7 binary JSON handshake frame sent on +// upstream connect: {"AuthToken":"","columns":N,"rows":N}. +type ttydHandshake struct { + AuthToken string `json:"AuthToken"` + Columns int `json:"columns"` + Rows int `json:"rows"` +} + +// ttydResize is the JSON payload of a ttyd resize frame (opcode 0x31). +type ttydResize struct { + Columns int `json:"columns"` + Rows int `json:"rows"` +} + +// ttydAdapter is the service-specific upstream edge for the ttyd service. +// It owns the ttyd 1.7.7 handshake and the first-frame resize forwarding; +// after that the relay pipes frames opaquely. +type ttydAdapter struct { + upstreamURL string + // handshakeCols/Rows are the initial terminal size sent in the handshake. + handshakeCols int + handshakeRows int +} + +// newTTYDAdapter builds an adapter for the given upstream URL. +func newTTYDAdapter(upstreamURL string) *ttydAdapter { + return &ttydAdapter{ + upstreamURL: upstreamURL, + handshakeCols: 80, + handshakeRows: 24, + } +} + +// dialUpstream opens the upstream WS to ttyd with the tty subprotocol, +// performs the 1.7.7 binary JSON handshake, and returns the connection. +func (a *ttydAdapter) dialUpstream(ctx context.Context) (*ws.Conn, error) { + opts := &ws.DialOptions{ + Subprotocols: []string{"tty"}, + } + conn, _, err := ws.DialContext(ctx, a.upstreamURL, opts) + if err != nil { + return nil, fmt.Errorf("ttyd upstream dial failed: %w", err) + } + handshake, err := json.Marshal(ttydHandshake{ + AuthToken: "", + Columns: a.handshakeCols, + Rows: a.handshakeRows, + }) + if err != nil { + conn.Close() + return nil, fmt.Errorf("ttyd handshake marshal failed: %w", err) + } + if err := conn.WriteMessage(ws.MsgBinary, handshake); err != nil { + conn.Close() + return nil, fmt.Errorf("ttyd handshake send failed: %w", err) + } + log.Info("ttyd_upstream_connected", map[string]interface{}{ + "upstream": a.upstreamURL, + "columns": a.handshakeCols, + "rows": a.handshakeRows, + }) + return conn, nil +} + +// forwardFirstResize forwards the first browser frame to ttyd before the +// opaque pipe starts. The browser's first frame is a resize frame (opcode +// 0x31 + JSON {columns,rows}); forwarding it makes the terminal adopt the +// browser's initial size. Returns the frame so the caller can decide whether +// to also forward it through the pipe (it is consumed here and must not be +// re-sent). +func (a *ttydAdapter) forwardFirstResize(upstream *ws.Conn, firstFrame []byte) error { + if len(firstFrame) == 0 { + return nil + } + // The browser speaks the ttyd binary protocol directly, so its first + // frame is already a valid ttyd frame. Forward it unchanged. + if err := upstream.WriteMessage(ws.MsgBinary, firstFrame); err != nil { + return fmt.Errorf("ttyd first-frame forward failed: %w", err) + } + log.Info("ttyd_first_frame_forwarded", map[string]interface{}{ + "length": len(firstFrame), + }) + return nil +} + +// waitForTTYD polls the ttyd upstream until it accepts a connection or the +// timeout elapses. Used at startup so the control dial only happens after +// ttyd is reachable (startup order tmux → ttyd → control dial). +func waitForTTYD(ctx context.Context, upstreamURL string, timeout time.Duration) error { + opts := &ws.DialOptions{ + Subprotocols: []string{"tty"}, + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + conn, _, err := ws.DialContext(ctx, upstreamURL, opts) + if err == nil { + _ = conn.Close() + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(1 * time.Second): + } + } + return fmt.Errorf("ttyd did not become ready within %s", timeout) +} diff --git a/remote-bob/job-agent/internal/tunnel/ttyd_adapter_test.go b/remote-bob/job-agent/internal/tunnel/ttyd_adapter_test.go new file mode 100644 index 00000000..72e79729 --- /dev/null +++ b/remote-bob/job-agent/internal/tunnel/ttyd_adapter_test.go @@ -0,0 +1,636 @@ +package tunnel + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/CodeEngine/remote-bob-job-agent/internal/ws" +) + +// TestTTYDHandshake verifies the 1.7.7 binary JSON handshake +// {AuthToken,columns,rows} is sent on upstream connect. +func TestTTYDHandshake(t *testing.T) { + upstream := newTestTTYDUpstream(t) + defer upstream.close() + + adapter := newTTYDAdapter(upstream.wsURL) + conn, err := adapter.dialUpstream(context.Background()) + if err != nil { + t.Fatalf("dialUpstream failed: %v", err) + } + defer conn.Close() + + upstream.waitForHandshake(t) + upstream.mu.Lock() + hs := append([]byte(nil), upstream.handshake...) + upstream.mu.Unlock() + + var parsed map[string]interface{} + if err := json.Unmarshal(hs, &parsed); err != nil { + t.Fatalf("handshake is not valid JSON: %v (raw %q)", err, hs) + } + if _, ok := parsed["AuthToken"]; !ok { + t.Errorf("handshake missing AuthToken: %q", hs) + } + if parsed["columns"] == nil || parsed["rows"] == nil { + t.Errorf("handshake missing columns/rows: %q", hs) + } +} + +// TestTTYDHandshakeUsesTTYSubprotocol verifies the upstream dial requests the +// tty subprotocol. +func TestTTYDHandshakeUsesTTYSubprotocol(t *testing.T) { + var gotSubprotocol string + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + gotSubprotocol = r.Header.Get("Sec-WebSocket-Protocol") + conn, err := ws.Upgrade(w, r) + if err != nil { + return + } + defer conn.Close() + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + }) + server := httptest.NewServer(mux) + defer server.Close() + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + + adapter := newTTYDAdapter(wsURL) + conn, err := adapter.dialUpstream(context.Background()) + if err != nil { + t.Fatalf("dialUpstream failed: %v", err) + } + defer conn.Close() + + if !strings.Contains(gotSubprotocol, "tty") { + t.Errorf("Sec-WebSocket-Protocol = %q, want tty", gotSubprotocol) + } +} + +// TestFirstFrameResizeForwarded verifies the first browser frame is forwarded +// to ttyd before the opaque pipe starts. +func TestFirstFrameResizeForwarded(t *testing.T) { + upstream := newTestTTYDUpstream(t) + defer upstream.close() + + adapter := newTTYDAdapter(upstream.wsURL) + conn, err := adapter.dialUpstream(context.Background()) + if err != nil { + t.Fatalf("dialUpstream failed: %v", err) + } + defer conn.Close() + upstream.waitForHandshake(t) + + resize := []byte{'1', '{', '"', 'c', 'o', 'l', 'u', 'm', 'n', 's', '"', ':', '1', '2', '0', ',', '"', 'r', 'o', 'w', 's', '"', ':', '3', '0', '}'} + if err := adapter.forwardFirstResize(conn, resize); err != nil { + t.Fatalf("forwardFirstResize failed: %v", err) + } + upstream.waitForFrame(t, resize) +} + +// TestWaitForTTYDRetries verifies waitForTTYD tolerates ttyd not immediately +// ready and eventually succeeds. +func TestWaitForTTYDRetries(t *testing.T) { + // Start with no server; the probe should retry. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- waitForTTYD(ctx, "ws://127.0.0.1:1", 3*time.Second) + }() + + // waitForTTYD should still be retrying after 500ms (not exited). + select { + case err := <-done: + t.Fatalf("waitForTTYD exited early with %v", err) + case <-time.After(500 * time.Millisecond): + } + + // Now bring up a ttyd-like server; the probe should connect. + upstream := newTestTTYDUpstream(t) + defer upstream.close() + // The probe uses the same URL pattern; point it at the live server. + // (The original goroutine is probing port 1, so start a fresh one.) + cancel() + <-done + + ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel2() + if err := waitForTTYD(ctx2, upstream.wsURL, 3*time.Second); err != nil { + t.Fatalf("waitForTTYD failed against live server: %v", err) + } +} + +// TestWaitForTTYDTimeout verifies waitForTTYD gives up after the timeout. +func TestWaitForTTYDTimeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + start := time.Now() + err := waitForTTYD(ctx, "ws://127.0.0.1:1", 300*time.Millisecond) + if err == nil { + t.Fatal("waitForTTYD succeeded against a dead port") + } + if time.Since(start) < 300*time.Millisecond { + t.Errorf("waitForTTYD returned too early: %v", time.Since(start)) + } +} + +// TestPipeFramesOpaque verifies pipeFrames preserves opcode and payload +// byte-identically for text and binary frames. +func TestPipeFramesOpaque(t *testing.T) { + // Build two connected WS pairs via an echo server. + upstream := newTestTTYDUpstream(t) + defer upstream.close() + + adapter := newTTYDAdapter(upstream.wsURL) + upConn, err := adapter.dialUpstream(context.Background()) + if err != nil { + t.Fatal(err) + } + defer upConn.Close() + upstream.waitForHandshake(t) + + // A client connection to the same upstream. + opts := &ws.DialOptions{Subprotocols: []string{"tty"}} + client, _, err := ws.DialContext(context.Background(), upstream.wsURL, opts) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + // Pipe client → upstream. + errCh := make(chan error, 1) + go pipeFrames(client, upConn, errCh) + + // Binary frame round-trip. + binPayload := []byte{0x00, 0x01, 0x02, 0xff, 0xfe} + if err := client.WriteMessage(ws.MsgBinary, binPayload); err != nil { + t.Fatal(err) + } + upstream.waitForFrame(t, binPayload) + + // Text frame round-trip. + textPayload := []byte("hello opaque text") + if err := client.WriteMessage(ws.MsgText, textPayload); err != nil { + t.Fatal(err) + } + upstream.waitForFrame(t, textPayload) +} + +// TestConcurrentRelaysIndependent verifies two concurrent relays on one agent +// each get their own upstream and frames do not cross. +func TestConcurrentRelaysIndependent(t *testing.T) { + upstream := newTestTTYDUpstream(t) + defer upstream.close() + + ts := newTestControlServer() + defer ts.close() + + cfg := testConfigForServer(t, ts.server.URL) + cl := newControlLoop(cfg, newTTYDAdapter(upstream.wsURL)) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- cl.run(ctx) }() + waitForRegistrations(t, ts, 1) + + relay1 := ts.relay + relay2 := ts.relay + + conn := ts.currentConn() + // Dial relay-a first and wait for its relay connection so the connection + // order is deterministic (conns[0] = relay-a, conns[1] = relay-b). + if err := ts.sendRelayOpen(conn, "relay-a", "ttyd", "relay-token-a"); err != nil { + t.Fatal(err) + } + relay1.waitForConn(t, 1) + upstream.waitForHandshake(t) + + if err := ts.sendRelayOpen(conn, "relay-b", "ttyd", "relay-token-b"); err != nil { + t.Fatal(err) + } + relay2.waitForConn(t, 2) + time.Sleep(100 * time.Millisecond) + + // Frames from each relay reach the upstream. + relay1.sendFrameOn(0, []byte("frame-for-a")) + relay2.sendFrameOn(1, []byte("frame-for-b")) + upstream.waitForFrame(t, []byte("frame-for-a")) + upstream.waitForFrame(t, []byte("frame-for-b")) + + // Closing one relay must not close the other. + if err := ts.sendRelayClose(conn, "relay-a"); err != nil { + t.Fatal(err) + } + time.Sleep(100 * time.Millisecond) + relay2.sendFrameOn(1, []byte("still-alive")) + upstream.waitForFrame(t, []byte("still-alive")) +} + +// TestIdleTimeoutExits verifies the idle timer fires when no relays are +// active. +func TestIdleTimeoutExits(t *testing.T) { + ts := newTestControlServer() + defer ts.close() + + cfg := testConfigForServer(t, ts.server.URL) + cfg.IdleTimeout = 150 * time.Millisecond + cl := newControlLoop(cfg, newTTYDAdapter("ws://127.0.0.1:7080")) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := cl.idleTimeout(ctx) + + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("idle timeout did not fire") + } +} + +// TestIdleTimeoutSuppressedByActiveRelay verifies an active relay suppresses +// the idle timer. +func TestIdleTimeoutSuppressedByActiveRelay(t *testing.T) { + upstream := newTestTTYDUpstream(t) + defer upstream.close() + + ts := newTestControlServer() + defer ts.close() + + cfg := testConfigForServer(t, ts.server.URL) + cfg.IdleTimeout = 200 * time.Millisecond + cl := newControlLoop(cfg, newTTYDAdapter(upstream.wsURL)) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := cl.idleTimeout(ctx) + + // Open a relay and keep it active past the timeout. + relay := ts.relay + ctrlDone := make(chan error, 1) + go func() { ctrlDone <- cl.run(ctx) }() + waitForRegistrations(t, ts, 1) + + conn := ts.currentConn() + if err := ts.sendRelayOpen(conn, "relay-idle", "ttyd", "relay-token-idle"); err != nil { + t.Fatal(err) + } + upstream.waitForHandshake(t) + relay.waitForConn(t, 1) + + // The idle timer must not fire while the relay is active. + select { + case <-done: + t.Fatal("idle timeout fired while a relay was active") + case <-time.After(600 * time.Millisecond): + } + + // After the relay closes, the timer should fire. + ts.sendRelayClose(conn, "relay-idle") + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("idle timeout did not fire after relay closed") + } +} + +// TestIdleTimeoutActivityResets verifies relay activity resets the timer. +func TestIdleTimeoutActivityResets(t *testing.T) { + cl := newControlLoop(&Config{IdleTimeout: 200 * time.Millisecond}, newTTYDAdapter("ws://127.0.0.1:7080")) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := cl.idleTimeout(ctx) + + // Send activity every 100ms; the timer should keep resetting and never + // fire within 700ms. + for i := 0; i < 7; i++ { + select { + case <-done: + t.Fatal("idle timeout fired despite activity") + case <-time.After(100 * time.Millisecond): + } + select { + case cl.activityCh <- struct{}{}: + default: + } + } +} + +// TestGracefulShutdownClosesRelays verifies shutdown closes active relays. +func TestGracefulShutdownClosesRelays(t *testing.T) { + upstream := newTestTTYDUpstream(t) + defer upstream.close() + + ts := newTestControlServer() + defer ts.close() + + cfg := testConfigForServer(t, ts.server.URL) + cl := newControlLoop(cfg, newTTYDAdapter(upstream.wsURL)) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ctrlDone := make(chan error, 1) + go func() { ctrlDone <- cl.run(ctx) }() + waitForRegistrations(t, ts, 1) + + relay := ts.relay + conn := ts.currentConn() + if err := ts.sendRelayOpen(conn, "relay-shutdown", "ttyd", "relay-token-shutdown"); err != nil { + t.Fatal(err) + } + upstream.waitForHandshake(t) + relay.waitForConn(t, 1) + + // Shutdown closes all relays; the upstream sees a close. + cl.closeAllRelays() + upstream.waitForClose(t) +} + +// TestControlLoopStopsOnContextCancel verifies the control loop exits cleanly +// when the context is cancelled. +func TestControlLoopStopsOnContextCancel(t *testing.T) { + ts := newTestControlServer() + defer ts.close() + + cfg := testConfigForServer(t, ts.server.URL) + cl := newControlLoop(cfg, newTTYDAdapter("ws://127.0.0.1:7080")) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- cl.run(ctx) }() + waitForRegistrations(t, ts, 1) + + cancel() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("control loop did not stop on context cancel") + } +} + +// TestHealthState verifies the health state transitions. +func TestHealthState(t *testing.T) { + hs := &healthState{} + if hs.isTmuxReady() { + t.Error("health should start not-ready") + } + hs.setTmuxReady(true) + if !hs.isTmuxReady() { + t.Error("health should be ready after setTmuxReady(true)") + } + hs.setTmuxReady(false) + if hs.isTmuxReady() { + t.Error("health should be not-ready after setTmuxReady(false)") + } +} + +// TestHealthServerStatus verifies the health endpoint returns 200 when tmux +// is ready and 503 otherwise. +func TestHealthServerStatus(t *testing.T) { + cfg := &Config{HealthPort: "0", AgentID: "agent-1"} + hs := &healthState{} + hs.setTmuxReady(true) + + mux := http.NewServeMux() + handleHealth := func(w http.ResponseWriter, r *http.Request) { + healthHandler(cfg, hs, w) + } + mux.HandleFunc("/health", handleHealth) + server := httptest.NewServer(mux) + defer server.Close() + + resp, err := http.Get(server.URL + "/health") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("ready health status = %d, want 200", resp.StatusCode) + } + var body map[string]string + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["status"] != "healthy" { + t.Errorf("ready health body = %v, want status healthy", body) + } + + hs.setTmuxReady(false) + resp2, err := http.Get(server.URL + "/health") + if err != nil { + t.Fatal(err) + } + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusServiceUnavailable { + t.Errorf("not-ready health status = %d, want 503", resp2.StatusCode) + } + var body2 map[string]string + if err := json.NewDecoder(resp2.Body).Decode(&body2); err != nil { + t.Fatal(err) + } + if body2["status"] != "unhealthy" { + t.Errorf("not-ready health body = %v, want status unhealthy", body2) + } +} + +// TestHealth503WindowObservable verifies the 503 window is observable: after +// tmux death flips the state to not-ready, the health server keeps serving +// 503 {"status":"unhealthy"} (it is not shut down immediately), and the +// process stays alive through the window. +func TestHealth503WindowObservable(t *testing.T) { + cfg := &Config{HealthPort: "0", AgentID: "agent-1"} + hs := &healthState{} + hs.setTmuxReady(true) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + healthHandler(cfg, hs, w) + })) + defer server.Close() + + // Healthy while tmux is up. + resp, err := http.Get(server.URL + "/health") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("health before tmux death = %d, want 200", resp.StatusCode) + } + + // tmux dies: the monitor flips the state to not-ready. The health server + // must keep serving 503 for the whole grace window, not shut down. + hs.setTmuxReady(false) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + resp, err := http.Get(server.URL + "/health") + if err != nil { + t.Fatalf("health server stopped serving during the 503 window: %v", err) + } + var body map[string]string + _ = json.NewDecoder(resp.Body).Decode(&body) + code := resp.StatusCode + resp.Body.Close() + if code != http.StatusServiceUnavailable { + t.Fatalf("health during 503 window = %d, want 503", code) + } + if body["status"] != "unhealthy" { + t.Fatalf("health body during 503 window = %v, want status unhealthy", body) + } + time.Sleep(50 * time.Millisecond) + } + // The server is still alive after the window (shutdown is the caller's + // job, after the bounded grace period). + resp, err = http.Get(server.URL + "/health") + if err != nil { + t.Fatalf("health server not alive after the 503 window: %v", err) + } + resp.Body.Close() +} + +// TestHealth503WindowBounded verifies the unhealthy window is bounded: the +// runtime proceeds with shutdown only after the configured grace period, so +// the health server serves 503 for at most ~grace and then shuts down. +func TestHealth503WindowBounded(t *testing.T) { + cfg := &Config{HealthPort: "0", AgentID: "agent-1", TmuxDeathGrace: 300 * time.Millisecond} + hs := &healthState{} + hs.setTmuxReady(true) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + healthHandler(cfg, hs, w) + })) + defer server.Close() + + // tmux dies; the runtime keeps the health server alive for the grace + // period before shutting it down (mirrors the runtime's tmux-death + // goroutine: wait for the grace period, then cancel the server context). + hs.setTmuxReady(false) + start := time.Now() + select { + case <-time.After(cfg.TmuxDeathGrace): + case <-ctx.Done(): + } + elapsed := time.Since(start) + if elapsed < cfg.TmuxDeathGrace { + t.Errorf("shutdown began after %v, want at least the grace period %v", elapsed, cfg.TmuxDeathGrace) + } + // The 503 window must not extend far beyond the grace period. + if elapsed > cfg.TmuxDeathGrace+2*time.Second { + t.Errorf("shutdown began after %v, want bounded by grace + slack", elapsed) + } +} + +// TestTmuxDeath503WindowEndToEnd verifies the full decoupling with a real +// tmux session: killing the session flips health to 503 {"status":"unhealthy"} +// and the health server keeps serving 503 (it is not shut down immediately), +// while the tmux monitor reports the death. This is the observable behavior +// VAL-AGENT-004/005 require. +func TestTmuxDeath503WindowEndToEnd(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux not available") + } + session := "health-test-" + strconv.Itoa(os.Getpid()) + // Create a detached tmux session running a long-lived command. + if err := exec.Command("tmux", "new-session", "-d", "-s", session, "sleep", "300").Run(); err != nil { + t.Fatalf("failed to create tmux session: %v", err) + } + defer exec.Command("tmux", "kill-session", "-t", session).Run() + + cfg := &Config{HealthPort: "0", AgentID: session, TmuxDeathGrace: 2 * time.Second} + rt := NewRuntime(cfg) + rt.tmuxPollInterval = 100 * time.Millisecond + rt.state.setTmuxReady(true) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + healthHandler(cfg, rt.state, w) + })) + defer server.Close() + + // Healthy while tmux is up. + resp, err := http.Get(server.URL + "/health") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("health before tmux kill = %d, want 200", resp.StatusCode) + } + + // Start the tmux monitor; it must not shut anything down by itself. + died := rt.monitorTmux(ctx) + + // Kill the tmux session. + if err := exec.Command("tmux", "kill-session", "-t", session).Run(); err != nil { + t.Fatalf("failed to kill tmux session: %v", err) + } + + // The monitor reports the death. + select { + case <-died: + case <-time.After(5 * time.Second): + t.Fatal("tmux monitor never reported the session death") + } + if rt.state.isTmuxReady() { + t.Error("health state still ready after tmux death") + } + + // The health server keeps serving 503 {"status":"unhealthy"} throughout + // the grace window — it is not shut down on tmux death. + deadline := time.Now().Add(1500 * time.Millisecond) + for time.Now().Before(deadline) { + resp, err := http.Get(server.URL + "/health") + if err != nil { + t.Fatalf("health server stopped serving during the 503 window: %v", err) + } + var body map[string]string + _ = json.NewDecoder(resp.Body).Decode(&body) + code := resp.StatusCode + resp.Body.Close() + if code != http.StatusServiceUnavailable { + t.Fatalf("health after tmux death = %d, want 503", code) + } + if body["status"] != "unhealthy" { + t.Fatalf("health body after tmux death = %v, want status unhealthy", body) + } + time.Sleep(50 * time.Millisecond) + } +} + +// TestNoInsecureSkipVerify is a source-level guard: the tunnel package must +// never disable TLS verification. +func TestNoInsecureSkipVerify(t *testing.T) { + // The dial options used by the control loop must not set InsecureSkipVerify. + cl := newControlLoop(&Config{}, newTTYDAdapter("ws://127.0.0.1:7080")) + if cl.dialOpts != nil && cl.dialOpts.TLSConfig != nil && cl.dialOpts.TLSConfig.InsecureSkipVerify { + t.Error("control dialer disables TLS verification (InsecureSkipVerify)") + } +} + +// TestRunTokenNeverInURL verifies the run token never appears in the control +// URL. +func TestRunTokenNeverInURL(t *testing.T) { + url := controlURL("ws://gateway:8080/ws", "agent-1") + if strings.Contains(url, "run-token") || strings.Contains(url, "token=") { + t.Errorf("control URL contains a token: %q", url) + } +} + +var _ = sync.Mutex{} diff --git a/remote-bob/job-agent/internal/ws/conn.go b/remote-bob/job-agent/internal/ws/conn.go new file mode 100644 index 00000000..8d653059 --- /dev/null +++ b/remote-bob/job-agent/internal/ws/conn.go @@ -0,0 +1,478 @@ +// Package ws implements a minimal WebSocket client and server using only the +// Go standard library (net/http, bufio, crypto/sha1, encoding/base64, etc.). +// +// RFC 6455 compliance notes: +// - Supports text (0x1) and binary (0x2) data frames. +// - Supports ping (0x9) / pong (0xA) control frames. +// - Close (0x8) frame is sent on graceful shutdown. +// - Client frames are masked; server frames are unmasked (per spec). +// - Fragmented frames are NOT supported — ttyd never sends them. +package ws + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "crypto/sha1" //nolint:gosec — required by RFC 6455 + "crypto/tls" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" +) + +// Frame message types (RFC 6455 §11.8). +const ( + MsgText = 1 + MsgBinary = 2 + MsgClose = 8 + MsgPing = 9 + MsgPong = 10 +) + +// Conn is a WebSocket connection backed by a raw net.Conn. +// +// Concurrency model: +// - ReadFrame must be called from a single goroutine only (bufio.Reader is +// not goroutine-safe). Control-frame responses (pong) are sent via writeMu. +// - WriteFrame / WriteFrameMasked / WriteJSON are goroutine-safe. +// - Close is safe to call from any goroutine and is idempotent. +type Conn struct { + conn net.Conn + rw *bufio.ReadWriter + writeMu sync.Mutex // serialises all writes + once sync.Once + doneCh chan struct{} + closeCode int // populated on a close frame from the peer +} + +// CloseCode returns the WebSocket close code from the peer's close frame, +// or 0 if no close frame has been received. +func (c *Conn) CloseCode() int { + return c.closeCode +} + +// Close sends a Close frame and closes the underlying connection. +func (c *Conn) Close() error { + c.writeMu.Lock() + _ = c.writeFrameLocked(MsgClose, nil, false) + c.writeMu.Unlock() + + err := c.conn.Close() + c.once.Do(func() { close(c.doneCh) }) + return err +} + +// Done returns a channel that is closed when Close is called or the connection dies. +func (c *Conn) Done() <-chan struct{} { + return c.doneCh +} + +// ReadFrame reads the next complete data frame from the connection. +// Must be called from a single goroutine only. +// Control frames (ping/pong/close) are handled transparently: +// - Ping → send Pong and loop. +// - Close → record close code, return io.EOF. +// - Pong → discard and loop. +func (c *Conn) ReadFrame() (Frame, error) { + for { + msgType, payload, err := c.readRawFrame() + if err != nil { + c.once.Do(func() { close(c.doneCh) }) + return Frame{}, err + } + switch msgType { + case MsgPing: + c.writeMu.Lock() + _ = c.writeFrameLocked(MsgPong, payload, false) + c.writeMu.Unlock() + case MsgClose: + // Extract the 2-byte close code if present. + if len(payload) >= 2 { + c.closeCode = int(payload[0])<<8 | int(payload[1]) + } + c.writeMu.Lock() + _ = c.writeFrameLocked(MsgClose, nil, false) + c.writeMu.Unlock() + c.once.Do(func() { close(c.doneCh) }) + return Frame{}, io.EOF + case MsgPong: + // discard + default: + return Frame{MessageType: msgType, Payload: payload}, nil + } + } +} + +// Frame is a single decoded WebSocket frame. +type Frame struct { + MessageType int + Payload []byte +} + +// WriteFrame sends an unmasked WebSocket frame (server → client). +// Safe to call concurrently from multiple goroutines. +func (c *Conn) WriteFrame(msgType int, payload []byte) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return c.writeFrameLocked(msgType, payload, false) +} + +// WriteFrameMasked sends a masked frame (client → server, required by RFC 6455). +// Safe to call concurrently from multiple goroutines. +func (c *Conn) WriteFrameMasked(msgType int, payload []byte) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return c.writeFrameLocked(msgType, payload, true) +} + +// WriteJSON marshals v as JSON and sends it as a masked text frame. +// Safe to call concurrently from multiple goroutines. +func (c *Conn) WriteJSON(v interface{}) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + return c.WriteFrameMasked(MsgText, data) +} + +// ReadMessage reads the next data frame and returns its type and payload. +// This is a convenience wrapper. Must be called from a single goroutine only. +func (c *Conn) ReadMessage() (msgType int, payload []byte, err error) { + f, err := c.ReadFrame() + return f.MessageType, f.Payload, err +} + +// WriteMessage sends a frame with the given type and payload. +// The frame is masked (client→server semantics). +// Safe to call concurrently from multiple goroutines. +func (c *Conn) WriteMessage(msgType int, payload []byte) error { + return c.WriteFrameMasked(msgType, payload) +} + +// IsCloseError reports whether err signals a WebSocket close with one of the +// given codes. err must be io.EOF (the value ReadFrame returns on close). +func IsCloseError(conn *Conn, err error, codes ...int) bool { + if err != io.EOF { + return false + } + code := conn.CloseCode() + for _, c := range codes { + if code == c { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// RFC 6455 framing +// --------------------------------------------------------------------------- + +func (c *Conn) readRawFrame() (msgType int, payload []byte, err error) { + b0, err := c.rw.ReadByte() + if err != nil { + return 0, nil, err + } + opcode := int(b0 & 0x0F) + + b1, err := c.rw.ReadByte() + if err != nil { + return 0, nil, err + } + masked := b1&0x80 != 0 + length := int64(b1 & 0x7F) + + switch length { + case 126: + var ext uint16 + if err = binary.Read(c.rw, binary.BigEndian, &ext); err != nil { + return 0, nil, err + } + length = int64(ext) + case 127: + var ext uint64 + if err = binary.Read(c.rw, binary.BigEndian, &ext); err != nil { + return 0, nil, err + } + length = int64(ext) + } + + var maskKey [4]byte + if masked { + if _, err = io.ReadFull(c.rw, maskKey[:]); err != nil { + return 0, nil, err + } + } + + payload = make([]byte, length) + if _, err = io.ReadFull(c.rw, payload); err != nil { + return 0, nil, err + } + if masked { + for i := range payload { + payload[i] ^= maskKey[i%4] + } + } + return opcode, payload, nil +} + +func (c *Conn) writeFrameLocked(msgType int, payload []byte, masked bool) error { + length := len(payload) + + var header []byte + header = append(header, byte(0x80|msgType)) + + maskBit := byte(0) + if masked { + maskBit = 0x80 + } + + switch { + case length <= 125: + header = append(header, maskBit|byte(length)) + case length <= 65535: + header = append(header, maskBit|126) + header = append(header, byte(length>>8), byte(length)) + default: + header = append(header, maskBit|127) + var ext [8]byte + binary.BigEndian.PutUint64(ext[:], uint64(length)) + header = append(header, ext[:]...) + } + + var maskKey [4]byte + if masked { + if _, err := rand.Read(maskKey[:]); err != nil { + return err + } + header = append(header, maskKey[:]...) + } + + frame := make([]byte, 0, len(header)+length) + frame = append(frame, header...) + + if masked { + maskedPayload := make([]byte, length) + for i, b := range payload { + maskedPayload[i] = b ^ maskKey[i%4] + } + frame = append(frame, maskedPayload...) + } else { + frame = append(frame, payload...) + } + + _, err := c.rw.Write(frame) + if err != nil { + return err + } + return c.rw.Flush() +} + +// --------------------------------------------------------------------------- +// Server upgrade (HTTP → WebSocket) +// --------------------------------------------------------------------------- + +const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +// Upgrade performs the server-side WebSocket handshake. +// It accepts any origin and echoes back the "tty" subprotocol when requested. +func Upgrade(w http.ResponseWriter, r *http.Request) (*Conn, error) { + if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + http.Error(w, "not a websocket upgrade", http.StatusBadRequest) + return nil, fmt.Errorf("not a websocket upgrade") + } + + key := r.Header.Get("Sec-Websocket-Key") + if key == "" { + http.Error(w, "missing Sec-Websocket-Key", http.StatusBadRequest) + return nil, fmt.Errorf("missing Sec-Websocket-Key") + } + + h := sha1.New() //nolint:gosec + h.Write([]byte(key + wsGUID)) + accept := base64.StdEncoding.EncodeToString(h.Sum(nil)) + + hj, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "server does not support hijacking", http.StatusInternalServerError) + return nil, fmt.Errorf("hijacking not supported") + } + conn, brw, err := hj.Hijack() + if err != nil { + return nil, fmt.Errorf("hijack: %w", err) + } + + var preamble []byte + if brw.Reader.Buffered() > 0 { + preamble = make([]byte, brw.Reader.Buffered()) + _, _ = io.ReadFull(brw.Reader, preamble) + } + + br := bufio.NewReaderSize(conn, 4096) + bw := bufio.NewWriterSize(conn, 4096) + if len(preamble) > 0 { + br = bufio.NewReaderSize(io.MultiReader(bytes.NewReader(preamble), conn), 4096) + } + rw := bufio.NewReadWriter(br, bw) + + resp := "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-Websocket-Accept: " + accept + "\r\n" + + if proto := r.Header.Get("Sec-Websocket-Protocol"); strings.Contains(proto, "tty") { + resp += "Sec-Websocket-Protocol: tty\r\n" + } + resp += "\r\n" + + if _, err := rw.WriteString(resp); err != nil { + conn.Close() + return nil, fmt.Errorf("write handshake: %w", err) + } + if err := rw.Flush(); err != nil { + conn.Close() + return nil, fmt.Errorf("flush handshake: %w", err) + } + + return &Conn{conn: conn, rw: rw, doneCh: make(chan struct{})}, nil +} + +// --------------------------------------------------------------------------- +// Client dial (WebSocket client handshake) +// --------------------------------------------------------------------------- + +// DialOptions configures a WebSocket dial. +type DialOptions struct { + // ExtraHeaders are additional HTTP headers sent with the upgrade request. + ExtraHeaders map[string]string + // Subprotocols is the list of subprotocols to advertise (e.g. ["tty"]). + Subprotocols []string + // TLSConfig overrides TLS settings for wss:// connections. + TLSConfig *tls.Config +} + +// DialContext opens a WebSocket connection to rawURL, honouring ctx for +// cancellation. For wss:// the connection is wrapped in TLS. +func DialContext(ctx context.Context, rawURL string, opts *DialOptions) (*Conn, *http.Response, error) { + if opts == nil { + opts = &DialOptions{} + } + + u, err := url.Parse(rawURL) + if err != nil { + return nil, nil, fmt.Errorf("dial: parse url %q: %w", rawURL, err) + } + + useTLS := false + host := u.Host + switch u.Scheme { + case "ws": + if !strings.Contains(host, ":") { + host += ":80" + } + case "wss": + useTLS = true + if !strings.Contains(host, ":") { + host += ":443" + } + default: + return nil, nil, fmt.Errorf("dial: unsupported scheme %q", u.Scheme) + } + + // Dial with context cancellation support. + dialer := &net.Dialer{} + rawConn, err := dialer.DialContext(ctx, "tcp", host) + if err != nil { + return nil, nil, fmt.Errorf("dial tcp %s: %w", host, err) + } + + var netConn net.Conn = rawConn + if useTLS { + tlsCfg := opts.TLSConfig + if tlsCfg == nil { + tlsCfg = &tls.Config{ServerName: u.Hostname()} //nolint:gosec + } + tlsConn := tls.Client(rawConn, tlsCfg) + if err := tlsConn.HandshakeContext(ctx); err != nil { + rawConn.Close() + return nil, nil, fmt.Errorf("tls handshake: %w", err) + } + netConn = tlsConn + } + + // Generate random nonce. + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + netConn.Close() + return nil, nil, err + } + key := base64.StdEncoding.EncodeToString(nonce) + + requestPath := u.RequestURI() + if requestPath == "" { + requestPath = "/" + } + + var sb strings.Builder + sb.WriteString("GET ") + sb.WriteString(requestPath) + sb.WriteString(" HTTP/1.1\r\nHost: ") + sb.WriteString(u.Host) + sb.WriteString("\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ") + sb.WriteString(key) + sb.WriteString("\r\nSec-WebSocket-Version: 13\r\n") + if len(opts.Subprotocols) > 0 { + sb.WriteString("Sec-WebSocket-Protocol: ") + sb.WriteString(strings.Join(opts.Subprotocols, ", ")) + sb.WriteString("\r\n") + } + for k, v := range opts.ExtraHeaders { + sb.WriteString(k) + sb.WriteString(": ") + sb.WriteString(v) + sb.WriteString("\r\n") + } + sb.WriteString("\r\n") + + if _, err := netConn.Write([]byte(sb.String())); err != nil { + netConn.Close() + return nil, nil, fmt.Errorf("dial write request: %w", err) + } + + br := bufio.NewReaderSize(netConn, 4096) + resp, err := http.ReadResponse(br, nil) + if err != nil { + netConn.Close() + return nil, nil, fmt.Errorf("dial read response: %w", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusSwitchingProtocols { + netConn.Close() + return nil, resp, fmt.Errorf("dial: unexpected status %d (want 101)", resp.StatusCode) + } + + h := sha1.New() //nolint:gosec + h.Write([]byte(key + wsGUID)) + expected := base64.StdEncoding.EncodeToString(h.Sum(nil)) + if got := resp.Header.Get("Sec-Websocket-Accept"); got != expected { + netConn.Close() + return nil, resp, fmt.Errorf("dial: invalid Sec-Websocket-Accept: got %q want %q", got, expected) + } + + bw := bufio.NewWriterSize(netConn, 4096) + conn := &Conn{ + conn: netConn, + rw: bufio.NewReadWriter(br, bw), + doneCh: make(chan struct{}), + } + return conn, resp, nil +} diff --git a/remote-bob/remote-bob b/remote-bob/remote-bob new file mode 100755 index 00000000..af1766e7 --- /dev/null +++ b/remote-bob/remote-bob @@ -0,0 +1,547 @@ +#!/bin/bash +# Remote Bob — Code Engine launcher +# +# Workflow: +# 1. ./remote-bob --setup # first time: provision infra + build images +# 2. ./remote-bob --new-session # start a job run and open the browser +# 3. ./remote-bob --connect # reopen the browser (session still running) +# 4. ./remote-bob --end-session # kill the job run (infra stays) +# 5. ./remote-bob --setup # rebuild after code changes (re-runs step 1) +# 6. ./remote-bob --clean # remove all IBM Cloud resources +# +# ./remote-bob with no arguments prints the current session status. +# +# All config is read from .env (default) or --config=FILE. +# .env is never modified at runtime — all session state comes from IBM Cloud. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/../ai/common.sh" + +# ── Paths ───────────────────────────────────────────────────────────────────── +APISERVER_DIR="$SCRIPT_DIR/apiserver" +BROWSER_HTML="$SCRIPT_DIR/browser-client/single-session.html" + +# ── Argument parsing ────────────────────────────────────────────────────────── +CONFIG_FILE="$SCRIPT_DIR/.env" +CMD="" # setup | new-session | connect | end-session | clean | status + +for arg in "$@"; do + case "$arg" in + --config=*) CONFIG_FILE="${arg#--config=}" ;; + --setup) CMD=setup ;; + --new-session) CMD=new-session ;; + --connect) CMD=connect ;; + --end-session) CMD=end-session ;; + --clean) CMD=clean ;; + --help|-h) + cat < + +Commands: + --setup Provision IBM Cloud resources and build container images. + Safe to re-run — idempotent. Re-run after code changes. + --new-session Submit a new agent job run and open the browser. + Requires a completed --setup. + --connect Reopen the browser for an already-running session. + --end-session Terminate the running job run (infra stays for next session). + --clean Remove ALL provisioned IBM Cloud resources. + (no args) Print current session status. + +Required config (.env): + BOBSHELL_API_KEY Bob Shell API key (https://bob.ibm.com) + GATEWAY_PASSWORD Basic-auth password for the browser login + IBMCLOUD_API_KEY IBM Cloud API key + +EOF + exit 0 + ;; + *) print_error "Unknown argument: $arg"; exit 2 ;; + esac +done + +[ -f "$CONFIG_FILE" ] || { print_error "Config file not found: $CONFIG_FILE"; exit 1; } + +# ── Load config (env takes precedence over file) ────────────────────────────── +while IFS= read -r line; do + [[ -z "$line" || "$line" == \#* ]] && continue + key="${line%%=*}"; val="${line#*=}" + [[ -z "${!key:-}" ]] && export "$key=$val" +done < "$CONFIG_FILE" + +# ── Validate required variables ─────────────────────────────────────────────── +check_var() { [[ -n "${!1:-}" ]] || { print_error "Missing required config: $1"; exit 1; }; } +check_var BOBSHELL_API_KEY +check_var GATEWAY_PASSWORD +# IBMCLOUD_API_KEY is needed for every command that talks to IBM Cloud. +# Only --connect (pure browser open after ibmcloud_setup) could skip it, +# but we require it anyway since it always calls ibmcloud_setup. +check_var IBMCLOUD_API_KEY + +# ── Defaults ────────────────────────────────────────────────────────────────── +CE_REGION="${CE_REGION:-us-east}" +CE_PROJECT="${CE_PROJECT:-remote-bob--ce-project}" +RESOURCE_GROUP="${RESOURCE_GROUP:-remote-bob--rg}" +APISERVER_APP_NAME="${APISERVER_APP_NAME:-remote-bob-apiserver}" +JOB_NAME="${JOB_NAME:-remote-bob-job-agent}" +DEFAULT_CPU="${DEFAULT_CPU:-1}" +DEFAULT_MEMORY="${DEFAULT_MEMORY:-2G}" +DEFAULT_TIMEOUT="${DEFAULT_TIMEOUT:-86400}" +CHROME_DEBUG_PORT="${CHROME_DEBUG_PORT:-}" + +if (( DEFAULT_TIMEOUT > 86400 )); then DEFAULT_TIMEOUT=86400; fi + +# ── ENCRYPTION_KEY — in-memory only, never written to .env ─────────────────── +if [[ -z "${ENCRYPTION_KEY:-}" ]]; then + ENCRYPTION_KEY="$(openssl rand -base64 32)" +fi + +# ── Auth helper ─────────────────────────────────────────────────────────────── +auth_header() { echo "Basic $(echo -n "admin:${GATEWAY_PASSWORD}" | base64)"; } + +# ── IBM Cloud login + CE project select ─────────────────────────────────────── +ibmcloud_setup() { + command -v ibmcloud &>/dev/null || { print_error "ibmcloud CLI not found"; exit 1; } + command -v jq &>/dev/null || { print_error "jq not found"; exit 1; } + + ensure_plugin_is_up_to_date code-engine + + print_msg "\nLogging in to IBM Cloud (region: $CE_REGION, resource group: $RESOURCE_GROUP)" + # Login without -g first: ibmcloud login -g exits 0 even when targeting + # fails (emits "No resource group targeted" on stdout), so we always do + # login + target as two explicit steps to ensure targeting is reliable. + ibmcloud login --apikey "$IBMCLOUD_API_KEY" -r "$CE_REGION" -q &>/dev/null \ + || { print_error "IBM Cloud login failed — check IBMCLOUD_API_KEY"; exit 1; } + + # Ensure the resource group exists, then target it. + ibmcloud resource group "$RESOURCE_GROUP" -q 2>/dev/null \ + || ibmcloud resource group-create "$RESOURCE_GROUP" -q 2>/dev/null + ibmcloud target -g "$RESOURCE_GROUP" -q &>/dev/null \ + || { print_error "Failed to target resource group '$RESOURCE_GROUP'"; exit 1; } + + print_msg "\nEnsuring Code Engine project '$CE_PROJECT'" + if ! ibmcloud ce project select --name "$CE_PROJECT" -q 2>/dev/null; then + ibmcloud ce project create --name "$CE_PROJECT" -q 2>/dev/null + ibmcloud ce project select --name "$CE_PROJECT" -q 2>/dev/null + fi +} + +# ── Provision CE secrets (idempotent) ───────────────────────────────────────── +provision() { + print_msg "\nProvisioning CE secrets" + + upsert_secret() { + local name="$1"; shift + if ibmcloud ce secret get --name "$name" &>/dev/null; then + ibmcloud ce secret update --name "$name" "$@" -q + else + ibmcloud ce secret create --name "$name" "$@" -q + fi + } + + upsert_secret remote-bob-gateway \ + --from-literal GATEWAY_PASSWORD="$GATEWAY_PASSWORD" \ + --from-literal ENCRYPTION_KEY="$ENCRYPTION_KEY" + + upsert_secret remote-bob-bobshell --from-literal BOBSHELL_API_KEY="$BOBSHELL_API_KEY" +} + +# ── Deploy / update the apiserver application ───────────────────────────────── +deploy_apiserver() { + print_msg "\nDeploying apiserver app '$APISERVER_APP_NAME'" + if ibmcloud ce application get --name "$APISERVER_APP_NAME" &>/dev/null; then + ibmcloud ce application update \ + --name "$APISERVER_APP_NAME" \ + --build-source "$APISERVER_DIR" \ + --min-scale 0 --wait + else + ibmcloud ce application create \ + --name "$APISERVER_APP_NAME" \ + --build-source "$APISERVER_DIR" \ + --port 8080 --min-scale 0 --max-scale 10 \ + --cpu 0.5 --memory 1G --concurrency 10 \ + --env-from-secret remote-bob-gateway \ + --wait + fi +} + +# ── Deploy the job-agent job definition (delete+recreate when URL changes) ──── +deploy_job_agent() { + local gateway_url="$1" + local gateway_wss="${gateway_url/https:/wss:}/ws" + + if ibmcloud ce job get --name "$JOB_NAME" &>/dev/null; then + local current_wss + current_wss="$(ibmcloud ce job get --name "$JOB_NAME" --output json \ + | jq -r '[.spec.template.containers[0].env[]? | select(.name=="GATEWAY_WSS") | .value][0] // empty')" + if [[ "$current_wss" == "$gateway_wss" ]]; then + print_msg "\nJob '$JOB_NAME' is current — skipping rebuild" + return + fi + print_msg "\nGateway URL changed — recreating job '$JOB_NAME'" + ibmcloud ce job delete --name "$JOB_NAME" --force -q + fi + + print_msg "\nCreating job '$JOB_NAME' (first build may take up to 20 min)..." + local job_create_out build_run + # Separate declaration from assignment so set -e sees the exit code. + job_create_out="$(ibmcloud ce job create \ + --name "$JOB_NAME" \ + --build-source "$SCRIPT_DIR" \ + --build-dockerfile "job-agent/Dockerfile" \ + --mode task \ + --cpu "$DEFAULT_CPU" --memory "$DEFAULT_MEMORY" \ + --maxexecutiontime "$DEFAULT_TIMEOUT" \ + --retrylimit 0 \ + --env "GATEWAY_WSS=$gateway_wss" \ + --env-from-secret remote-bob-bobshell \ + --no-wait 2>&1)" || { echo "$job_create_out"; print_error "Job create failed"; exit 1; } + echo "$job_create_out" + build_run="$(echo "$job_create_out" | sed -n "s/.*Submitting build run '\([^']*\)'.*/\1/p" || true)" + + if [[ -n "$build_run" ]]; then + print_msg "\nWaiting for job build run '$build_run'..." + local elapsed=0 + while (( elapsed < 1500 )); do + local br_status + br_status="$(ibmcloud ce buildrun get --name "$build_run" --output json 2>/dev/null \ + | jq -r '.status // empty' 2>/dev/null || true)" + case "$br_status" in + succeeded) + print_success "\nJob build completed successfully" + break + ;; + failed) + ibmcloud ce buildrun logs --name "$build_run" 2>/dev/null | tail -40 || true + print_error "Job build run failed: $build_run"; exit 1 + ;; + esac + sleep 10; elapsed=$(( elapsed + 10 )) + done + if (( elapsed >= 1500 )); then + print_error "Job build timed out after 1500s"; exit 1 + fi + fi +} + +# ── Submit a new job run and wait for the agent to connect ──────────────────── +start_session() { + local api_url="$1" + + # Wait for the apiserver to be healthy. + print_msg "\nWaiting for apiserver health check..." + local i + for i in $(seq 1 60); do + curl -sf -o /dev/null "${api_url}/healthz" 2>/dev/null && break + sleep 2 + done + curl -sf -o /dev/null "${api_url}/healthz" || { print_error "Apiserver not healthy"; exit 1; } + + # Generate a unique agent ID and issue a run token. + AGENT_ID="agent-$(openssl rand -hex 8)" + API_SERVER_URL="$api_url" + + local run_token + run_token="$(curl -sf -X POST "${api_url}/auth/runs?agent=${AGENT_ID}" \ + -H "Authorization: $(auth_header)" | jq -r '.run_token // empty')" + [[ -n "$run_token" ]] || { print_error "POST /auth/runs returned no run_token"; exit 1; } + + local ce_job_run_name="${JOB_NAME}-${AGENT_ID#agent-}" + ibmcloud ce jobrun submit \ + --name "$ce_job_run_name" \ + --job "$JOB_NAME" \ + --mode task \ + --env "AGENT_ID=$AGENT_ID" \ + --env "RUN_TOKEN=$run_token" \ + --cpu "$DEFAULT_CPU" \ + --memory "$DEFAULT_MEMORY" \ + --maxexecutiontime "$DEFAULT_TIMEOUT" + + wait_for_agent "$api_url" "$AGENT_ID" 480 || exit 1 +} + +# ── Wait for agent to report "ready" ───────────────────────────────────────── +wait_for_agent() { + local api_base="$1" agent_id="$2" timeout="${3:-480}" elapsed=0 + print_msg "\nWaiting for agent $agent_id (up to ${timeout}s)..." + while (( elapsed < timeout )); do + local status + status="$(curl -sf "${api_base}/agents" \ + -H "Authorization: $(auth_header)" 2>/dev/null \ + | jq -r --arg id "$agent_id" '.[] | select(.agent_id==$id) | .status // empty' 2>/dev/null || true)" + [[ "$status" == "ready" ]] && { print_success "\nAgent ready after ${elapsed}s"; return 0; } + sleep 3; elapsed=$(( elapsed + 3 )) + done + print_error "Agent $agent_id not ready after ${timeout}s" + local run_name="${JOB_NAME}-${agent_id#agent-}" + ibmcloud ce jobrun logs --name "$run_name" 2>/dev/null | tail -40 || true + return 1 +} + +# ── Find a live session (sets AGENT_ID + API_SERVER_URL) ───────────────────── +# Returns 0 if a ready agent exists, 1 otherwise. +find_live_session() { + API_SERVER_URL="$(ibmcloud ce application get --name "$APISERVER_APP_NAME" \ + --output json 2>/dev/null | jq -r '.status.url // empty' 2>/dev/null || true)" + [[ -n "$API_SERVER_URL" ]] || return 1 + API_SERVER_URL="${API_SERVER_URL%/}" + + curl -sf -o /dev/null "${API_SERVER_URL}/healthz" 2>/dev/null || return 1 + + AGENT_ID="$(curl -sf "${API_SERVER_URL}/agents" \ + -H "Authorization: $(auth_header)" 2>/dev/null \ + | jq -r '[.[] | select(.status=="ready")][0].agent_id // empty' 2>/dev/null || true)" + [[ -n "$AGENT_ID" ]] +} + +# ── Check whether the job definition exists ─────────────────────────────────── +job_exists() { + ibmcloud ce job get --name "$JOB_NAME" &>/dev/null +} + +# ── Launch Chrome ───────────────────────────────────────────────────────────── +launch_chrome() { + local api_host="${API_SERVER_URL#https://}"; api_host="${api_host#http://}" + local url="file://${BROWSER_HTML}?apiHost=${api_host}&agent=${AGENT_ID}" + mkdir -p "$SCRIPT_DIR/tmp" + local user_data; user_data="$(mktemp -d "$SCRIPT_DIR/tmp/remote-bob-chrome.XXXXXX")" + + local chrome_bin="${CHROME_BIN:-}" + if [[ -z "$chrome_bin" ]]; then + chrome_bin="$(command -v google-chrome \ + || command -v google-chrome-stable \ + || command -v chromium \ + || command -v chromium-browser \ + || { [[ -x "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ]] \ + && echo "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; } \ + || true)" + fi + [[ -n "$chrome_bin" ]] || { + print_error "Chrome not found. Install Google Chrome or add CHROME_BIN=/path/to/chrome to your .env" + exit 1 + } + [[ -f "$BROWSER_HTML" ]] || { print_error "Browser client not found: $BROWSER_HTML"; exit 1; } + + local args=( --app="$url" --user-data-dir="$user_data" --new-window + --no-first-run --no-default-browser-check + --disable-extensions --disable-sync --disable-translate ) + [[ -n "$CHROME_DEBUG_PORT" ]] && args+=( "--remote-debugging-port=$CHROME_DEBUG_PORT" ) + + print_success "\nSession live — opening browser" + print_msg " Agent: $AGENT_ID" + print_msg " API: ${API_SERVER_URL}" + print_msg "\nSession continues in the background when you close the window." + print_msg "Run './remote-bob --end-session' to terminate.\n" + + "$chrome_bin" "${args[@]}" >/dev/null 2>&1 & + local pid=$! + + if [[ "$(uname)" == "Darwin" ]]; then + sleep 2 + while pgrep -f "$user_data" >/dev/null 2>&1; do + sleep 2 + done + else + wait "$pid" 2>/dev/null || true + fi + + rm -rf "$user_data" + print_msg "\nBrowser window closed. Press Ctrl+C to exit, or run './remote-bob --connect' to reopen.\n" +} + +# ═════════════════════════════════════════════════════════════════════════════ +# --setup — provision infra + build images (idempotent) +# ═════════════════════════════════════════════════════════════════════════════ +if [[ "$CMD" == setup ]]; then + print_msg "\n========== Remote Bob — Setup ==========" + + ibmcloud_setup + provision + deploy_apiserver + + API_SERVER_URL="$(ibmcloud ce application get --name "$APISERVER_APP_NAME" --output json \ + | jq -r '.status.url // empty')" + [[ -n "$API_SERVER_URL" ]] || { print_error "Could not determine apiserver URL"; exit 1; } + API_SERVER_URL="${API_SERVER_URL%/}" + + deploy_job_agent "$API_SERVER_URL" + + print_success "\n========== Setup complete — run './remote-bob --new-session' to start ==========\n" + exit 0 +fi + +# ═════════════════════════════════════════════════════════════════════════════ +# --new-session — submit a job run and open the browser +# ═════════════════════════════════════════════════════════════════════════════ +if [[ "$CMD" == new-session ]]; then + print_msg "\n========== Remote Bob — New Session ==========" + + ibmcloud_setup + + # Guard: setup must have been run first. + if ! ibmcloud ce application get --name "$APISERVER_APP_NAME" &>/dev/null; then + print_error "Apiserver not deployed. Run './remote-bob --setup' first." + exit 1 + fi + if ! job_exists; then + print_error "Job not built. Run './remote-bob --setup' first." + exit 1 + fi + + # Warn if a session is already live — don't start a second one. + if find_live_session; then + print_error "A session is already running (agent: $AGENT_ID)." + print_msg "Run './remote-bob --connect' to reopen it, or './remote-bob --end-session' to end it first." + exit 1 + fi + + API_SERVER_URL="$(ibmcloud ce application get --name "$APISERVER_APP_NAME" --output json \ + | jq -r '.status.url // empty')" + [[ -n "$API_SERVER_URL" ]] || { print_error "Could not determine apiserver URL"; exit 1; } + API_SERVER_URL="${API_SERVER_URL%/}" + + start_session "$API_SERVER_URL" + launch_chrome + exit 0 +fi + +# ═════════════════════════════════════════════════════════════════════════════ +# --connect — reopen the browser for a running session +# ═════════════════════════════════════════════════════════════════════════════ +if [[ "$CMD" == connect ]]; then + print_msg "\n========== Remote Bob — Connect ==========" + + ibmcloud_setup + + print_msg "\nLooking for live session..." + if ! find_live_session; then + print_error "No live session found (app not reachable or no ready agent)." + print_msg "Run './remote-bob --new-session' to start one." + exit 1 + fi + + print_msg "\nConnecting to session (agent: $AGENT_ID)" + launch_chrome + exit 0 +fi + +# ═════════════════════════════════════════════════════════════════════════════ +# --end-session — kill the job run (infra stays) +# ═════════════════════════════════════════════════════════════════════════════ +if [[ "$CMD" == end-session ]]; then + print_msg "\n========== Remote Bob — End Session ==========" + + ibmcloud_setup + + # Disconnect agent via the apiserver (best effort — server may already be down). + local_app_url="$(ibmcloud ce application get --name "$APISERVER_APP_NAME" \ + --output json 2>/dev/null | jq -r '.status.url // empty' 2>/dev/null || true)" + if [[ -n "$local_app_url" ]]; then + local_agent_id="$(curl -sf "${local_app_url%/}/agents" \ + -H "Authorization: $(auth_header)" 2>/dev/null \ + | jq -r '[.[] | select(.status=="ready")][0].agent_id // empty' 2>/dev/null || true)" + if [[ -n "$local_agent_id" ]]; then + print_msg "\nDisconnecting agent $local_agent_id" + curl -sf -X DELETE "${local_app_url%/}/agents/${local_agent_id}" \ + -H "Authorization: $(auth_header)" 2>/dev/null || true + fi + fi + + # Delete all running job runs for this job. + while IFS= read -r run_name; do + [[ -z "$run_name" ]] && continue + print_msg "\nDeleting job run: $run_name" + ibmcloud ce jobrun delete --name "$run_name" --force 2>/dev/null || true + done < <(ibmcloud ce jobrun list --job "$JOB_NAME" --output json 2>/dev/null \ + | jq -r '.[].name // empty' 2>/dev/null || true) + + print_success "\n========== Session ended — run './remote-bob --new-session' to start another ==========\n" + exit 0 +fi + +# ═════════════════════════════════════════════════════════════════════════════ +# --clean — remove all IBM Cloud resources +# ═════════════════════════════════════════════════════════════════════════════ +if [[ "$CMD" == clean ]]; then + print_msg "\n========== Remote Bob — Clean ==========" + + ibmcloud_setup + + print_msg "\nDeleting all job runs for job: $JOB_NAME" + while IFS= read -r run_name; do + [[ -z "$run_name" ]] && continue + print_msg " Deleting job run: $run_name" + ibmcloud ce jobrun delete --name "$run_name" --force 2>/dev/null || true + done < <(ibmcloud ce jobrun list --job "$JOB_NAME" --output json 2>/dev/null \ + | jq -r '.[].name // empty' 2>/dev/null || true) + + print_msg "\nDeleting job: $JOB_NAME" + ibmcloud ce job delete --name "$JOB_NAME" --force 2>/dev/null || true + + print_msg "\nDeleting app: $APISERVER_APP_NAME" + ibmcloud ce application delete --name "$APISERVER_APP_NAME" --force 2>/dev/null || true + + for secret in remote-bob-gateway remote-bob-bobshell; do + print_msg "\nDeleting secret: $secret" + ibmcloud ce secret delete --name "$secret" --force 2>/dev/null || true + done + + print_msg "\nDeleting CE project: $CE_PROJECT" + ibmcloud ce project delete --name "$CE_PROJECT" --hard --force 2>/dev/null || true + + print_msg "\nDeleting resource group: $RESOURCE_GROUP" + ibmcloud resource group-delete "$RESOURCE_GROUP" --force 2>/dev/null || true + + print_success "\n========== Clean complete ==========\n" + exit 0 +fi + +# ═════════════════════════════════════════════════════════════════════════════ +# (no args) — print status +# ═════════════════════════════════════════════════════════════════════════════ +print_msg "\n========== Remote Bob — Status ==========" + +ibmcloud_setup + +app_exists=false +job_exists_flag=false +live_session=false + +if ibmcloud ce application get --name "$APISERVER_APP_NAME" &>/dev/null; then + app_exists=true +fi +if ibmcloud ce job get --name "$JOB_NAME" &>/dev/null; then + job_exists_flag=true +fi +if [[ "$app_exists" == true ]] && find_live_session; then + live_session=true +fi + +print_msg "" +if [[ "$app_exists" == false && "$job_exists_flag" == false ]]; then + print_msg " Infrastructure: not set up" + print_msg " Session: none" + print_msg "" + print_msg " Next step: ./remote-bob --setup" +elif [[ "$app_exists" == true && "$job_exists_flag" == true && "$live_session" == false ]]; then + print_msg " Infrastructure: ready" + print_msg " Session: none running" + print_msg "" + print_msg " Next step: ./remote-bob --new-session" +elif [[ "$live_session" == true ]]; then + print_msg " Infrastructure: ready" + print_msg " Session: running (agent: $AGENT_ID)" + print_msg "" + print_msg " Next steps: ./remote-bob --connect (open browser)" + print_msg " ./remote-bob --end-session (stop session)" +else + print_msg " Infrastructure: partial (app=$app_exists, job=$job_exists_flag)" + print_msg " Session: unknown" + print_msg "" + print_msg " Next step: ./remote-bob --setup" +fi +print_msg ""