From 51b07917c201226455371f0b74b13256914ef2e5 Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:02:49 -0700 Subject: [PATCH] Build generalized remote tunnel toolkit --- .editorconfig | 15 + .gitattributes | 8 + .github/CODEOWNERS | 5 + .github/ISSUE_TEMPLATE/bug_report.yml | 42 + .github/ISSUE_TEMPLATE/config.yml | 8 + .github/dependabot.yml | 25 + .github/pull_request_template.md | 21 + .github/workflows/ci.yml | 104 + .github/workflows/codeql.yml | 42 + .github/workflows/secret-scan.yml | 31 + .gitignore | 14 + .markdownlint-cli2.jsonc | 9 + CHANGELOG.md | 20 + CONTRIBUTING.md | 40 + README.md | 367 ++- SECURITY.md | 37 + SUPPORT.md | 13 + THIRD_PARTY_NOTICES.md | 40 + assets/vscode-interface/LICENSE | 395 +++ assets/vscode-interface/README.md | 35 + assets/vscode-interface/SOURCE.json | 11 + .../fixtures/protocol-v5-flow.json | 91 + bin/remote-ssh-tunnel-relay.js | 86 + config/relay.env.example | 17 + .../com.remote-ssh-tunnel.relay.plist.example | 41 + .../user/remote-ssh-tunnel-relay.service | 34 + docs/architecture-and-operations.md | 238 ++ package-lock.json | 2110 +++++++++++++++++ package.json | 31 + pyproject.toml | 39 + requirements.txt | 1 + scripts/validate_repository.py | 183 ++ src/relay/config.js | 119 + src/relay/errors.js | 51 + src/relay/github-token.js | 43 + src/relay/logger.js | 18 + src/relay/sdk-client.js | 196 ++ src/relay/supervisor.js | 73 + src/remote_ssh_tunnel/__init__.py | 5 + src/remote_ssh_tunnel/cli.py | 153 ++ src/remote_ssh_tunnel/output.py | 34 + src/remote_ssh_tunnel/rpc.py | 340 +++ tests/node/cli-smoke.test.js | 17 + tests/node/config.test.js | 103 + tests/node/github-token.test.js | 84 + tests/node/relay.test.js | 107 + tests/node/sdk-client.test.js | 85 + tests/python/test_cli.py | 29 + tests/python/test_output.py | 29 + tests/python/test_rpc.py | 247 ++ 50 files changed, 5885 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/secret-scan.yml create mode 100644 .gitignore create mode 100644 .markdownlint-cli2.jsonc create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 SUPPORT.md create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 assets/vscode-interface/LICENSE create mode 100644 assets/vscode-interface/README.md create mode 100644 assets/vscode-interface/SOURCE.json create mode 100644 assets/vscode-interface/fixtures/protocol-v5-flow.json create mode 100755 bin/remote-ssh-tunnel-relay.js create mode 100644 config/relay.env.example create mode 100644 deploy/launchd/com.remote-ssh-tunnel.relay.plist.example create mode 100644 deploy/systemd/user/remote-ssh-tunnel-relay.service create mode 100644 docs/architecture-and-operations.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100755 scripts/validate_repository.py create mode 100644 src/relay/config.js create mode 100644 src/relay/errors.js create mode 100644 src/relay/github-token.js create mode 100644 src/relay/logger.js create mode 100644 src/relay/sdk-client.js create mode 100644 src/relay/supervisor.js create mode 100644 src/remote_ssh_tunnel/__init__.py create mode 100644 src/remote_ssh_tunnel/cli.py create mode 100644 src/remote_ssh_tunnel/output.py create mode 100644 src/remote_ssh_tunnel/rpc.py create mode 100644 tests/node/cli-smoke.test.js create mode 100644 tests/node/config.test.js create mode 100644 tests/node/github-token.test.js create mode 100644 tests/node/relay.test.js create mode 100644 tests/node/sdk-client.test.js create mode 100644 tests/python/test_cli.py create mode 100644 tests/python/test_output.py create mode 100644 tests/python/test_rpc.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..64d3bf0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.py] +indent_size = 4 + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..95fabd6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto eol=lf +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +assets/vscode-interface/LICENSE whitespace=-trailing-space diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..ad79661 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +* @sumitake + +/assets/vscode-interface/ @sumitake +/.github/workflows/ @sumitake +/SECURITY.md @sumitake diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..7ec409e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,42 @@ +name: Bug report +description: Report a reproducible defect using sanitized information only. +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: Do not include tokens, tunnel IDs, cluster IDs, account names, hostnames, or private paths. + - type: textarea + id: description + attributes: + label: Description + description: What happened, and what did you expect? + validations: + required: true + - type: input + id: revision + attributes: + label: Commit or version + validations: + required: true + - type: input + id: environment + attributes: + label: Environment + description: Operating system plus Node.js and Python versions. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Minimal reproduction + description: Use placeholders for all deployment-specific values. + validations: + required: true + - type: checkboxes + id: sanitation + attributes: + label: Sanitization + options: + - label: I removed credentials and deployment-specific identifiers from this report. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..4a9b979 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Private security report + url: https://github.com/sumitake/Remote-SSH-Tunnel/security/advisories/new + about: Report vulnerabilities privately. + - name: VS Code Remote Tunnels documentation + url: https://code.visualstudio.com/docs/remote/tunnels#_using-the-code-cli + about: Get support for the standalone CLI and Microsoft tunnel service. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ae1c550 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: ["dependencies", "github-actions"] + + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: ["dependencies", "javascript"] + + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: ["dependencies", "python"] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..377212e --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +# Pull Request + +## Summary + +- What changed: +- Why: + +## Verification + +- [ ] Node tests and executable smoke check pass +- [ ] Python tests, Ruff lint, and Ruff format checks pass +- [ ] Dependency audit and repository validator pass +- [ ] No real credentials, tunnel metadata, hostnames, usernames, or private paths are present +- [ ] Microsoft-derived assets remain isolated and attributed +- [ ] User-visible changes are documented in `CHANGELOG.md` + +## Security impact + +Describe changes to authentication, port exposure, command execution, protocol +parsing, output handling, dependencies, services, or licensing. Write `None` only +when none of those boundaries changed. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3f68034 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,104 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + node: + name: Node ${{ matrix.node }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [20, 22, 24] + steps: + - name: Check out repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - name: Install locked dependencies without lifecycle scripts + run: npm ci --ignore-scripts + - name: Run Node checks + run: npm run check + - name: Lint Markdown + run: npm run lint:markdown + - name: Audit runtime dependencies + run: npm audit --audit-level=moderate + - name: Inspect package contents + run: npm pack --dry-run + + python: + name: Python ${{ matrix.python }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.12", "3.13", "3.14"] + steps: + - name: Check out repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.python }} + cache: pip + - name: Install project and test tools + run: python -m pip install '.[dev]' + - name: Lint + run: ruff check . + - name: Check formatting + run: ruff format --check . + - name: Test + run: python -m pytest + - name: Build wheel + run: python -m pip wheel . --no-deps --wheel-dir /tmp/remote-ssh-tunnel-wheel + + dependency-audit: + name: Python Dependency Audit + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + cache: pip + - name: Install pip-audit + run: python -m pip install pip-audit==2.10.1 + - name: Audit locked runtime requirement + run: pip-audit --strict --progress-spinner=off --cache-dir /tmp/pip-audit-cache --requirement requirements.txt + + metadata: + name: Repository Metadata + runs-on: ubuntu-latest + steps: + - name: Check out full history + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + - name: Validate repository contracts + run: python scripts/validate_repository.py + - name: Lint GitHub Actions workflows + run: | + go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + "$(go env GOPATH)/bin/actionlint" + - name: Check whitespace + run: git diff --check HEAD^ diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..48c97bb --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,42 @@ +name: CodeQL + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: "23 6 * * 4" + workflow_dispatch: + +permissions: + contents: read + security-events: write + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze ${{ matrix.label }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - language: javascript-typescript + label: JavaScript/TypeScript + - language: python + label: Python + steps: + - name: Check out repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Initialize CodeQL + uses: github/codeql-action/init@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3 + with: + languages: ${{ matrix.language }} + queries: security-extended + - name: Analyze + uses: github/codeql-action/analyze@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000..bbc8259 --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,31 @@ +name: Secret Scan + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: "41 5 * * 1" + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +concurrency: + group: secret-scan-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + gitleaks: + name: Gitleaks + runs-on: ubuntu-latest + steps: + - name: Check out full history + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - name: Scan repository history + uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..60d0e67 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.DS_Store +.env +.env.* +!.env.example +.pytest_cache/ +.ruff_cache/ +.venv/ +__pycache__/ +*.egg-info/ +*.log +build/ +coverage/ +dist/ +node_modules/ diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..f4db6be --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,9 @@ +{ + "config": { + "MD013": false, + "MD024": { + "siblings_only": true + }, + "MD033": false + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6eb7c4f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2026-07-11 + +### Added + +- Loopback-only Microsoft dev-tunnels relay with an explicit port allowlist. +- Bounded MessagePack RPC client with direct argv execution and safe output rendering. +- Host and client deployment workflows, service-manager examples, and recovery guidance. +- Isolated CC-BY-4.0 interface fixtures and third-party attribution. +- Matrix CI, CodeQL, Gitleaks, Dependabot, repository validation, and public contribution files. + +[Unreleased]: https://github.com/sumitake/Remote-SSH-Tunnel/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/sumitake/Remote-SSH-Tunnel/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..300410a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,40 @@ +# Contributing + +## Development setup + +Use Node.js 20 or newer and Python 3.11 or newer. + +```bash +npm ci +python3 -m venv .venv +.venv/bin/python -m pip install '.[dev]' +``` + +Run the same checks required by CI: + +```bash +npm run check +npm audit --audit-level=moderate +.venv/bin/ruff check . +.venv/bin/ruff format --check . +.venv/bin/python -m pytest +.venv/bin/python scripts/validate_repository.py +``` + +Reinstall `.[dev]` after changing Python source before rerunning the installed +CLI. Editable installs are not used because Python 3.14 ignores the +underscore-prefixed `.pth` filenames currently emitted by common build backends. + +## Pull requests + +- Open a focused branch and include tests for behavior changes. +- Never commit tokens, real tunnel or cluster IDs, account names, hostnames, + private paths, captured extension binaries, or unredacted operational logs. +- Keep Microsoft-derived interface material under `assets/vscode-interface/` + and update its attribution, source revision, and modification notice. +- Pin every third-party GitHub Action to a full commit SHA. +- Update `CHANGELOG.md` for user-visible changes. + +By contributing, you agree that original code is provided under the repository's +MIT license and appropriately identified adapted assets remain under their stated +license. diff --git a/README.md b/README.md index 242f6da..1ecff83 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,367 @@ # Remote-SSH-Tunnel -Remote SSH Tunnel via VSCode Remote Development Extension + +[![CI](https://github.com/sumitake/Remote-SSH-Tunnel/actions/workflows/ci.yml/badge.svg)](https://github.com/sumitake/Remote-SSH-Tunnel/actions/workflows/ci.yml) +[![CodeQL](https://github.com/sumitake/Remote-SSH-Tunnel/actions/workflows/codeql.yml/badge.svg)](https://github.com/sumitake/Remote-SSH-Tunnel/actions/workflows/codeql.yml) +[![Secret Scan](https://github.com/sumitake/Remote-SSH-Tunnel/actions/workflows/secret-scan.yml/badge.svg)](https://github.com/sumitake/Remote-SSH-Tunnel/actions/workflows/secret-scan.yml) + +A headless, self-recovering client bridge for an existing Visual Studio Code +Remote Tunnel. It combines a loopback-only Microsoft dev-tunnels relay with a +bounded MessagePack RPC client so automation can reach explicitly forwarded +remote services without a desktop editor. + +This is an independent interoperability project. It does not create the host +tunnel, replace OpenSSH, redistribute VS Code, or provide a general remote shell. +The host must already run an authenticated VS Code CLI tunnel. + +## What it provides + +- A Node.js relay built on Microsoft's official dev-tunnels SDK. +- Friendly VS Code tunnel-name discovery or an explicit tunnel ID/cluster pair. +- A mandatory port allowlist and loopback-only listeners (`127.0.0.1`/`::1`). +- Automatic SDK reconnect plus a fresh-client recovery loop after terminal disconnects. +- A Python RPC CLI with direct executable/argv semantics and no implicit shell. +- Protocol, frame, stream-state, deadline, and aggregate-output bounds. +- Safe rendering of untrusted remote output; raw bytes require an explicit opt-in. +- systemd user and launchd examples for long-running client-side deployment. +- Matrix CI, CodeQL, Gitleaks, Dependabot, and repository/license validation. + +## System architecture + +```mermaid +flowchart LR + subgraph Host["Remote host"] + CLI["VS Code CLI tunnel service"] + Server["VS Code Server and remote RPC endpoint"] + CLI --> Server + end + + Cloud["Microsoft dev tunnels service"] + + subgraph Client["Automation client"] + Relay["Node relay\nConnect scope only"] + Ports["IPv4/IPv6 loopback\nallowlisted ports only"] + RPC["Bounded Python RPC client"] + RPC --> Ports --> Relay + end + + CLI <--> Cloud <--> Relay +``` + +The VS Code CLI owns host registration, authentication, VS Code Server, and the +outbound service connection. This repository's relay authenticates separately, +requests only a tunnel `connect` token, rejects ports outside its allowlist, and +opens local listeners only on loopback. The RPC CLI treats the local endpoint as +untrusted even after tunnel authentication. + +See [Architecture and operations](docs/architecture-and-operations.md) for the +full trust model, sequence diagrams, recovery states, deployment, update, and +decommission workflows. + +## Production lifecycle + +```mermaid +stateDiagram-v2 + [*] --> HostBoot + HostBoot --> TunnelOnline: VS Code CLI service starts + TunnelOnline --> RelayConnecting: client service starts + RelayConnecting --> Ready: authenticated + filtered + connected + Ready --> SDKReconnect: transient network loss + SDKReconnect --> Ready: session recovered + SDKReconnect --> FreshClient: terminal disconnect + FreshClient --> RelayConnecting: bounded backoff + RelayConnecting --> Stopped: invalid config or auth rejection + Ready --> Stopped: operator shutdown + Stopped --> [*] +``` + +There are three recovery layers: + +1. The VS Code CLI service returns after a host restart. +2. The dev-tunnels SDK reconnects recoverable sessions while preserving the + pre-installed port filter. +3. The relay supervisor disposes a terminal client, creates a fresh filtered + client, and reconnects with capped exponential backoff. The operating-system + service manager restarts the process after an unrecoverable process failure. + +Configuration and authentication errors fail closed instead of creating a +restart storm. + +## Deployment flow + +```mermaid +flowchart TD + A["Install standalone VS Code CLI on host"] --> B["Run first authenticated code tunnel"] + B --> C["Install code tunnel service"] + C --> D["Clone this repository on client"] + D --> E["Install locked Node and Python dependencies"] + E --> F["Authenticate GitHub CLI"] + F --> G["Set tunnel name and explicit ports"] + G --> H["Start relay and verify loopback listeners"] + H --> I["Run bounded RPC command"] + I --> J["Install optional client service"] +``` + +## Requirements + +### Remote host + +- A supported 64-bit Linux host for the CLI-only example (`x86_64` or `aarch64`). +- Outbound HTTPS access to the VS Code and dev-tunnels services. +- A GitHub or Microsoft account accepted by VS Code Remote Tunnels. + +### Automation client + +- Node.js 20 or newer. +- Python 3.11 or newer. +- GitHub CLI (`gh`) authenticated to the same account that can access the tunnel. +- Outbound HTTPS and WebSocket access to the dev-tunnels service. + +## 1. Install the CLI-only host + +The official [VS Code Remote Tunnels CLI instructions](https://code.visualstudio.com/docs/remote/tunnels#_using-the-code-cli) +provide a standalone archive for machines without VS Code Desktop. The following +headless Linux commands select the current stable x64 or ARM64 archive and install +the `code` binary onto `PATH`: + +```bash +set -eu + +case "$(uname -m)" in + x86_64|amd64) cli_arch="x64" ;; + aarch64|arm64) cli_arch="arm64" ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; +esac + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +curl --fail --location --proto '=https' --tlsv1.2 \ + "https://code.visualstudio.com/sha/download?build=stable&os=cli-alpine-${cli_arch}" \ + --output "$tmpdir/vscode_cli.tar.gz" +tar -xzf "$tmpdir/vscode_cli.tar.gz" -C "$tmpdir" +"$tmpdir/code" --version + +sudo install -m 0755 "$tmpdir/code" /usr/local/bin/code +code --version +``` + +Without root access, install to `~/.local/bin/code` and add that directory to +`PATH`. While running directly from the extracted standalone archive, use +`./code` in every command below. Use `code` only after installing it on `PATH`. + +Start the tunnel once to accept the server license, authenticate, and choose the +friendly machine name: + +```bash +code tunnel --accept-server-license-terms +``` + +The CLI prints a device-authentication URL and code. Complete authentication, +confirm the tunnel is reachable, then stop the foreground process with `Ctrl-C`. +Install the persistent service: + +```bash +code tunnel service install +``` + +The service is the normal production path and starts after device restarts. To +start the tunnel manually for initial setup or diagnosis, run: + +```bash +code tunnel +``` + +Useful lifecycle commands: + +```bash +code tunnel --help +code tunnel service uninstall +code tunnel unregister +``` + +On Linux systems where a user service stops when the user logs out, an +administrator may need `loginctl enable-linger `. Confirm that the +host's service remains active after a reboot before deploying the client relay. + +## 2. Install the client tools + +```bash +git clone https://github.com/sumitake/Remote-SSH-Tunnel.git +cd Remote-SSH-Tunnel + +npm ci +python3 -m venv .venv +.venv/bin/python -m pip install . +``` + +Authenticate the GitHub CLI. The relay reads its token from the credential store +without placing it in command-line arguments: + +```bash +gh auth login --hostname github.com +gh auth status --hostname github.com +``` + +`GITHUB_TOKEN` or `GH_TOKEN` is also accepted for ephemeral automation. The relay +copies the value once, deletes both variables from its process environment, and +does not launch child processes afterward. Never put a token in a relay CLI flag, +service file, checked-in environment file, or log. + +## 3. Configure and run the relay + +Copy the non-secret configuration example outside the repository: + +```bash +install -d -m 0700 "$HOME/.config/remote-ssh-tunnel" +install -m 0600 config/relay.env.example \ + "$HOME/.config/remote-ssh-tunnel/relay.env" +``` + +Set `REMOTE_SSH_TUNNEL_NAME` to the friendly name chosen by `code tunnel`. Set +`REMOTE_SSH_TUNNEL_PORTS` to only the remote ports this client needs. The relay +fails if the name matches zero or multiple VS Code tunnels. + +```bash +set -a +. "$HOME/.config/remote-ssh-tunnel/relay.env" +set +a +node bin/remote-ssh-tunnel-relay.js +``` + +For advanced deterministic selection, omit `REMOTE_SSH_TUNNEL_NAME` and set both +`REMOTE_SSH_TUNNEL_ID` and `REMOTE_SSH_TUNNEL_CLUSTER_ID`. Do not configure both +selection modes. + +Project logs contain status, retry count, delay, and allowed port numbers. They do +not contain credentials or tunnel selectors. The upstream SDK also emits local +forwarding notices with loopback addresses and port numbers. It mirrors the +configured `127.0.0.1` listener on `::1` when IPv6 loopback is available. Verify +that every listener is loopback-only: + +```bash +lsof -nP -iTCP -sTCP:LISTEN | grep -E '127\.0\.0\.1|\[::1\]' +``` + +## 4. Run a remote RPC command + +The RPC port must be one of the relay's explicit allowed ports and must expose a +compatible VS Code remote RPC endpoint. Run an executable and exact argv without +an implicit shell: + +```bash +.venv/bin/remote-ssh-tunnel-rpc \ + --port 45001 \ + -- /usr/bin/id -un +``` + +Forward only named environment variables: + +```bash +.venv/bin/remote-ssh-tunnel-rpc \ + --port 45001 \ + --pass-env LANG \ + -- /usr/bin/env +``` + +Shell syntax is never inferred. If shell behavior is intentional, name the shell +and its arguments explicitly: + +```bash +.venv/bin/remote-ssh-tunnel-rpc \ + --port 45001 \ + -- /bin/sh -lc 'printf "%s\\n" "$HOME"' +``` + +Remote output is decoded and control characters are visibly escaped by default. +`--raw-output` preserves bytes for trusted binary pipelines but is unsafe when +attached to a terminal. + +## 5. Run the client relay as a service + +The [systemd user template](deploy/systemd/user/remote-ssh-tunnel-relay.service) +assumes this repository is at `~/Remote-SSH-Tunnel` and configuration is at +`~/.config/remote-ssh-tunnel/relay.env`: + +```bash +install -d "$HOME/.config/systemd/user" +install -m 0644 deploy/systemd/user/remote-ssh-tunnel-relay.service \ + "$HOME/.config/systemd/user/" +systemctl --user daemon-reload +systemctl --user enable --now remote-ssh-tunnel-relay.service +systemctl --user status remote-ssh-tunnel-relay.service +journalctl --user -u remote-ssh-tunnel-relay.service -f +``` + +For macOS, edit the absolute paths and non-secret selector values in the +[launchd example](deploy/launchd/com.remote-ssh-tunnel.relay.plist.example), copy +it to `~/Library/LaunchAgents/`, and validate it with `plutil -lint` before +loading it. + +## Operational workflows + +| Workflow | Host | Client | Positive verification | +| --- | --- | --- | --- | +| Cold start | VS Code CLI service starts | relay service starts | loopback listeners exist; relay status is connected | +| Host or network power loss | service restarts after host boot | SDK reconnects or creates a fresh client | RPC read-only command returns expected output | +| Planned update | update CLI, then restart host service | pull a reviewed release, install locks, restart relay | tests pass and listeners remain loopback-only | +| Credential expiry | host reauthenticates if required | `gh auth status`, then restart relay | management lookup and connect succeed | +| Port change | expose the intended host port | replace explicit client allowlist and restart | old listener closes; only new listener opens | +| Decommission | uninstall service and unregister tunnel | stop relay, remove service/config | no tunnel registration and no local listeners | + +The exact recovery, rollback, health-check, and decommission procedures are in +[Architecture and operations](docs/architecture-and-operations.md). + +## Security model + +- **Least network exposure:** the configured listener address is the IPv4 + loopback; the SDK may mirror it on IPv6 loopback. A pre-connect event filter + cancels every unlisted port, and no wildcard interface is exposed. +- **Least tunnel privilege:** management requests ask only for the `connect` + scope. The project does not create, update, or delete tunnels. +- **Credential containment:** tokens never enter argv, config objects, examples, + logs, fixtures, or generic error serialization. +- **Direct execution:** command and argv remain separate. Environment forwarding + is empty by default and opt-in by variable name. +- **Hostile-protocol handling:** MessagePack buffers, maps, arrays, strings, + binary fields, stream transitions, deadlines, and total output are bounded. +- **Supply chain:** dependency locks, a zero-vulnerability npm audit, full-SHA + action pins, CodeQL, Gitleaks, Dependabot, and protected-branch checks. + +Read [SECURITY.md](SECURITY.md) before production deployment. + +## Caveats + +- The MessagePack RPC interface is not a stable public VS Code API. This release + supports observed protocol version 5 and fails closed on any other version. +- A compatible RPC endpoint must already be exposed by the remote VS Code + environment. The relay alone does not create it. +- VS Code documents Remote Tunnel instances as single-user/single-client systems. +- Microsoft applies tunnel count, bandwidth, and abuse-prevention limits that may + change independently of this repository. +- The VS Code CLI, Server, Remote extensions, and cloud service remain governed by + Microsoft's licenses, terms, availability, and authentication behavior. +- Loopback reduces network exposure but does not isolate mutually untrusted local + users who can access one another's processes or sockets. + +## Repository workflows + +- `CI`: Node.js 20/22/24 and Python 3.11/3.12/3.13/3.14 tests, lint, formatting, + package audit, executable smoke checks, and repository validation. +- `CodeQL`: JavaScript/TypeScript and Python analysis. +- `Secret Scan`: full-history Gitleaks scan on pushes, pull requests, and a weekly schedule. +- `Dependabot`: weekly GitHub Actions, npm, and pip update pull requests. +- `main`: pull request review, required checks, code-owner review, linear history, + conversation resolution, and no force pushes or deletion. + +## Licensing and attribution + +Original runtime code and repository documentation are available under the root +[MIT License](LICENSE). Generalized material under +[`assets/vscode-interface/`](assets/vscode-interface/README.md) is separately +attributed and licensed under CC-BY-4.0, matching Microsoft's +[`vscode-remote-release` repository license](https://github.com/microsoft/vscode-remote-release/blob/main/LICENSE-repository). + +No VS Code extension binary or product code is redistributed. Microsoft's +separate [extension product terms](https://github.com/microsoft/vscode-remote-release/blob/main/LICENSE-extensions) +continue to apply to the VS Code components users install independently. See +[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for dependency notices. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6cb9063 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,37 @@ +# Security Policy + +## Supported versions + +Security fixes are applied to the current default branch. No stable release is +supported until it is listed in this table. + +| Version | Supported | +| --- | --- | +| `main` | Yes | + +## Report a vulnerability + +Use GitHub's private vulnerability reporting for this repository. Do not open a +public issue containing credentials, real tunnel identifiers, hostnames, account +names, exploit details, or unredacted logs. + +Include the affected commit, operating system, Node.js and Python versions, a +minimal reproduction, and the expected security invariant. Maintainers will +acknowledge a complete report as capacity permits; this project does not promise +a fixed response or remediation SLA. + +## Security boundaries + +- The relay binds only to local loopback (`127.0.0.1` and the SDK's `::1` mirror) + and forwards only explicitly allowed ports. +- GitHub tokens are read from the environment once and scrubbed, or obtained from + the GitHub CLI credential store. Tokens must never appear in CLI arguments. +- The RPC endpoint and all remote output are untrusted. Protocol frames, stream + transitions, output volume, and execution arguments are validated and bounded. +- `--raw-output` disables terminal-control sanitization. Never use it when output + is attached to a terminal unless the remote command is fully trusted. +- Any local user able to connect to another user's loopback sockets is inside the + client-host trust boundary. Use normal operating-system account isolation. + +See [Security model](docs/architecture-and-operations.md#security-model) for the +full threat model and recovery behavior. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..a488855 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,13 @@ +# Support + +Use GitHub Issues for reproducible defects and documentation gaps. Include only +sanitized logs from this project's structured logger. Do not post credentials, +tunnel IDs, cluster IDs, account names, hostnames, or remote command output that +contains private information. + +This is an independent interoperability project, not a Microsoft-supported VS +Code component. Questions about VS Code Remote Tunnels, account authentication, +service limits, or the standalone CLI belong in the official +[VS Code Remote Tunnels documentation](https://code.visualstudio.com/docs/remote/tunnels#_using-the-code-cli). + +Security reports must follow [SECURITY.md](SECURITY.md), not a public issue. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..2b06de7 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,40 @@ +# Third-Party Notices + +This source repository is MIT-licensed except where a file or directory states +otherwise. + +## Microsoft dev tunnels SDK + +The Node.js runtime depends on version `1.3.50` of: + +- `@microsoft/dev-tunnels-connections` +- `@microsoft/dev-tunnels-contracts` +- `@microsoft/dev-tunnels-management` + +These packages are distributed under the MIT License. Source and license: + + +The relay also directly pins `vscode-jsonrpc` version `4.0.0` for the SDK's +cancellation-token contract. It is distributed under the MIT License. Source and +license: + +## MessagePack for Python + +The Python runtime depends on `msgpack` version `1.2.1`, distributed under the +Apache License 2.0. Source and license: + + +## VS Code Remote interface assets + +Generalized interface documentation and fixtures under +`assets/vscode-interface/` are adapted from or informed by material in the +Microsoft `vscode-remote-release` repository and are separately licensed under +CC-BY-4.0. That directory contains the license text, attribution, pinned source +revision, and modification notice. + +The VS Code CLI, VS Code Server, Remote extensions, `.vsix` packages, product +artwork, and proprietary binaries are not distributed by this repository. Those +products have separate Microsoft terms. See: + +- +- diff --git a/assets/vscode-interface/LICENSE b/assets/vscode-interface/LICENSE new file mode 100644 index 0000000..06c608d --- /dev/null +++ b/assets/vscode-interface/LICENSE @@ -0,0 +1,395 @@ +Attribution 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More_considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution 4.0 International Public License ("Public License"). To the +extent this Public License may be interpreted as a contract, You are +granted the Licensed Rights in consideration of Your acceptance of +these terms and conditions, and the Licensor grants You such rights in +consideration of benefits the Licensor receives from making the +Licensed Material available under these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/assets/vscode-interface/README.md b/assets/vscode-interface/README.md new file mode 100644 index 0000000..d388b54 --- /dev/null +++ b/assets/vscode-interface/README.md @@ -0,0 +1,35 @@ +# VS Code Remote Interface Assets + +This directory is the explicit licensing boundary for generalized interface +documentation and fixtures adapted from or informed by the Microsoft +[`vscode-remote-release`](https://github.com/microsoft/vscode-remote-release) +repository and observed VS Code Remote tunnel behavior. + +## Attribution + +- Creator: Microsoft Corporation and contributors. +- Source repository: +- Source revision: `1803940623da0ba648084b5ba0b1265b2b854ae4` +- Repository license: CC-BY-4.0, copied in [LICENSE](LICENSE). +- Source metadata: [SOURCE.json](SOURCE.json). + +## Modifications + +The project authors replaced deployment-specific values with placeholders, +reorganized the interface lifecycle into implementation-neutral JSON fixtures, +added explicit message directions and security expectations, and omitted all +product code, extension packages, binaries, artwork, logs, credentials, and host +metadata. The fixtures are descriptive test assets; they are not Microsoft APIs +or a promise of upstream compatibility. + +## Contents + +- [`fixtures/protocol-v5-flow.json`](fixtures/protocol-v5-flow.json): generalized + hello, version, spawn, stream, and result message sequence. + +Only files inside this directory are covered by this directory's CC-BY-4.0 +notice. Original project code elsewhere remains under the root MIT license. + +VS Code CLI, VS Code Server, and the Remote extensions are installed separately +by users and remain governed by Microsoft's separate product terms: + diff --git a/assets/vscode-interface/SOURCE.json b/assets/vscode-interface/SOURCE.json new file mode 100644 index 0000000..2574506 --- /dev/null +++ b/assets/vscode-interface/SOURCE.json @@ -0,0 +1,11 @@ +{ + "asset_name": "Generalized VS Code Remote interface fixtures", + "creator": "Microsoft Corporation and contributors", + "source_repository": "https://github.com/microsoft/vscode-remote-release", + "source_revision": "1803940623da0ba648084b5ba0b1265b2b854ae4", + "source_license_file": "LICENSE-repository", + "license": "CC-BY-4.0", + "retrieved": "2026-07-11", + "modified": true, + "modification_notice": "Deployment-specific values were replaced with placeholders; interface observations were reorganized into implementation-neutral fixtures; product code, binaries, artwork, and operational logs were omitted." +} diff --git a/assets/vscode-interface/fixtures/protocol-v5-flow.json b/assets/vscode-interface/fixtures/protocol-v5-flow.json new file mode 100644 index 0000000..7fd2bec --- /dev/null +++ b/assets/vscode-interface/fixtures/protocol-v5-flow.json @@ -0,0 +1,91 @@ +{ + "asset_license": "CC-BY-4.0", + "encoding": "concatenated MessagePack objects", + "protocol_version": 5, + "messages": [ + { + "direction": "server_to_client", + "purpose": "hello", + "message": { + "id": null, + "method": "version", + "params": { + "version": "", + "protocol_version": 5 + } + } + }, + { + "direction": "client_to_server", + "purpose": "version_handshake", + "message": { + "id": null, + "method": "version", + "params": { + "version": "", + "protocol_version": 5 + } + } + }, + { + "direction": "client_to_server", + "purpose": "direct_process_spawn", + "message": { + "id": "", + "method": "spawn", + "params": { + "command": "/path/to/executable", + "args": ["arg-one", "arg two"], + "cwd": null, + "env": {} + } + } + }, + { + "direction": "server_to_client", + "purpose": "stream_advertisement", + "message": { + "id": null, + "method": "streams_started", + "params": { + "for_request_id": "", + "stream_ids": ["", "", ""] + } + } + }, + { + "direction": "client_to_server", + "purpose": "close_stdin", + "message": { + "id": null, + "method": "stream_ended", + "params": { + "stream": "" + } + } + }, + { + "direction": "server_to_client", + "purpose": "output_chunk", + "message": { + "id": null, + "method": "stream_data", + "params": { + "stream": "", + "segment": "" + } + } + }, + { + "direction": "server_to_client", + "purpose": "command_result", + "message": { + "id": "", + "result": { + "exit_code": "", + "message": "" + } + } + } + ] +} diff --git a/bin/remote-ssh-tunnel-relay.js b/bin/remote-ssh-tunnel-relay.js new file mode 100755 index 0000000..ecc5fc2 --- /dev/null +++ b/bin/remote-ssh-tunnel-relay.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT + +import management from "@microsoft/dev-tunnels-management"; + +import { parseRelayConfig, relayHelp } from "../src/relay/config.js"; +import { publicErrorCategory } from "../src/relay/errors.js"; +import { acquireGitHubToken } from "../src/relay/github-token.js"; +import { createLogger } from "../src/relay/logger.js"; +import { createSdkRelayClient } from "../src/relay/sdk-client.js"; +import { superviseRelay } from "../src/relay/supervisor.js"; + +const { ManagementApiVersions, TunnelAuthenticationSchemes, TunnelManagementHttpClient } = + management; +const VERSION = "0.1.0"; +const log = createLogger(); + +function scheduleExitFallback(code) { + const forcedExit = setTimeout(() => process.exit(code), 250); + forcedExit.unref(); +} + +async function main() { + const config = parseRelayConfig(process.argv.slice(2)); + if (config.help) { + process.stdout.write(`${relayHelp()}\n`); + return; + } + if (config.version) { + process.stdout.write(`${VERSION}\n`); + return; + } + + const token = acquireGitHubToken({ githubUser: config.githubUser }); + const managementClient = new TunnelManagementHttpClient( + [{ name: "remote-ssh-tunnel", version: VERSION }], + ManagementApiVersions.Version20230927preview, + async () => `${TunnelAuthenticationSchemes.github} ${token}`, + ); + managementClient.enableEventsReporting = false; + managementClient.trace = () => {}; + + const abortController = new AbortController(); + const stop = () => abortController.abort(); + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + + const tunnelSelector = { + tunnelName: config.tunnelName, + tunnelId: config.tunnelId, + clusterId: config.clusterId, + }; + + log("info", "relay_starting", { ports: config.ports }); + try { + await superviseRelay({ + createClient: () => + createSdkRelayClient({ + managementClient, + tunnelSelector, + localForwardingHostAddress: config.localForwardingHostAddress, + hostId: config.hostId, + onStatus: (status) => log("info", "relay_status", { status }), + }), + tunnel: tunnelSelector, + allowedPorts: new Set(config.ports), + signal: abortController.signal, + onRetry: ({ failures, delay }) => + log("warn", "relay_retry", { attempt: failures, delay_ms: delay }), + }); + } finally { + process.removeListener("SIGINT", stop); + process.removeListener("SIGTERM", stop); + await managementClient.dispose(); + } + log("info", "relay_stopped"); + if (abortController.signal.aborted) { + scheduleExitFallback(0); + } +} + +main().catch((error) => { + log("error", "relay_failed", { category: publicErrorCategory(error) }); + process.exitCode = 1; + scheduleExitFallback(1); +}); diff --git a/config/relay.env.example b/config/relay.env.example new file mode 100644 index 0000000..3a0a251 --- /dev/null +++ b/config/relay.env.example @@ -0,0 +1,17 @@ +# Non-secret relay configuration. Do not add GitHub tokens to this file. + +# Recommended: the friendly name selected by `code tunnel` on the remote host. +REMOTE_SSH_TUNNEL_NAME=example-host + +# Comma-separated remote ports that may be exposed on local loopback. +REMOTE_SSH_TUNNEL_PORTS=45001 + +# Optional when multiple GitHub CLI accounts are authenticated locally. +# REMOTE_SSH_TUNNEL_GITHUB_USER=example-user + +# Optional when a tunnel has multiple simultaneously connected hosts. +# REMOTE_SSH_TUNNEL_HOST_ID=example-host-id + +# Advanced alternative to REMOTE_SSH_TUNNEL_NAME. Set both or neither. +# REMOTE_SSH_TUNNEL_ID=example-tunnel-id +# REMOTE_SSH_TUNNEL_CLUSTER_ID=example-cluster diff --git a/deploy/launchd/com.remote-ssh-tunnel.relay.plist.example b/deploy/launchd/com.remote-ssh-tunnel.relay.plist.example new file mode 100644 index 0000000..30c8906 --- /dev/null +++ b/deploy/launchd/com.remote-ssh-tunnel.relay.plist.example @@ -0,0 +1,41 @@ + + + + + Label + com.remote-ssh-tunnel.relay + + ProgramArguments + + /usr/local/bin/node + /Users/CHANGE_ME/Remote-SSH-Tunnel/bin/remote-ssh-tunnel-relay.js + + + EnvironmentVariables + + PATH + /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin + REMOTE_SSH_TUNNEL_NAME + example-host + REMOTE_SSH_TUNNEL_PORTS + 45001 + + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ThrottleInterval + 15 + ProcessType + Background + + StandardOutPath + /Users/CHANGE_ME/Library/Logs/remote-ssh-tunnel-relay.log + StandardErrorPath + /Users/CHANGE_ME/Library/Logs/remote-ssh-tunnel-relay.log + + diff --git a/deploy/systemd/user/remote-ssh-tunnel-relay.service b/deploy/systemd/user/remote-ssh-tunnel-relay.service new file mode 100644 index 0000000..b792c6b --- /dev/null +++ b/deploy/systemd/user/remote-ssh-tunnel-relay.service @@ -0,0 +1,34 @@ +[Unit] +Description=Loopback-only VS Code remote tunnel relay +Documentation=https://github.com/sumitake/Remote-SSH-Tunnel +Wants=network-online.target +After=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=%h/Remote-SSH-Tunnel +Environment=PATH=/usr/local/bin:/usr/bin:/bin +EnvironmentFile=%h/.config/remote-ssh-tunnel/relay.env +ExecStart=/usr/bin/env node %h/Remote-SSH-Tunnel/bin/remote-ssh-tunnel-relay.js +Restart=on-failure +RestartSec=15s +TimeoutStopSec=30s +UMask=0077 + +NoNewPrivileges=true +PrivateDevices=true +PrivateTmp=true +ProtectControlGroups=true +ProtectHome=read-only +ProtectKernelModules=true +ProtectKernelTunables=true +ProtectSystem=strict +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true + +[Install] +WantedBy=default.target diff --git a/docs/architecture-and-operations.md b/docs/architecture-and-operations.md new file mode 100644 index 0000000..23f9ba5 --- /dev/null +++ b/docs/architecture-and-operations.md @@ -0,0 +1,238 @@ +# Architecture and Operations + +This page is the detailed production reference for Remote-SSH-Tunnel. The root +README remains the deployment quick start. + +## Responsibility boundaries + +| Component | Runs on | Owns | Does not own | +| --- | --- | --- | --- | +| VS Code CLI | remote host | tunnel registration, host authentication, VS Code Server, host service lifecycle | client relay policy | +| Microsoft dev tunnels | cloud service | authenticated relay transport and tunnel access tokens | local process policy or RPC validation | +| Node relay | automation client | friendly-name lookup, connect-only token request, loopback listeners, port filtering, reconnect | host tunnel creation or remote command semantics | +| Python RPC CLI | automation client | protocol validation, direct command request, stream routing, limits, safe output | tunnel registration or arbitrary port discovery | +| systemd/launchd | each applicable machine | restart after boot or process failure | application protocol correctness | + +## End-to-end sequence + +```mermaid +sequenceDiagram + participant H as Remote host + participant C as VS Code CLI service + participant D as Dev tunnels service + participant R as Node relay + participant L as Loopback listener + participant P as Python RPC client + + H->>C: Boot and start service + C->>D: Authenticate host and publish tunnel + R->>D: Authenticate GitHub user + R->>D: Resolve friendly label and request Connect token + R->>R: Install immutable port filter + R->>D: Connect relay client + D-->>R: Advertise remote forwarded ports + R->>R: Cancel every port outside allowlist + R->>L: Bind accepted ports on IPv4/IPv6 loopback + P->>L: Open one selected RPC port + P->>P: Validate hello and protocol version 5 + P->>H: Send direct executable, argv, cwd, and opt-in env + H-->>P: Stream bounded stdout/stderr and exit status + P->>P: Escape control output unless raw mode was explicit +``` + +## Host bootstrap + +1. Download the current standalone VS Code CLI over HTTPS for the host architecture. +2. Run `./code tunnel` from the extracted directory, or `code tunnel` after the + binary is installed on `PATH`. +3. Accept the server license and complete device authentication. +4. Choose a unique friendly tunnel name and verify an interactive connection. +5. Stop the foreground tunnel and run `code tunnel service install`. +6. Reboot the host and prove the tunnel returns without an interactive login. + +The host service makes outbound connections; the documented VS Code design does +not require opening an inbound WAN firewall port. + +## Relay state machine + +```mermaid +flowchart TD + Start["Process start"] --> Config{"Configuration valid?"} + Config -- No --> Fatal["Fail closed"] + Config -- Yes --> Auth{"Credential available?"} + Auth -- No --> Fatal + Auth -- Yes --> Resolve["Resolve exactly one tunnel"] + Resolve --> Build["Create fresh SDK client"] + Build --> Filter["Install allowlist listener"] + Filter --> Connect["Connect with SDK retry/reconnect enabled"] + Connect --> Ready["Serve loopback ports"] + Ready --> Transient{"Connection lost"} + Transient -- SDK recovers --> Ready + Transient -- Final disconnect --> Dispose["Dispose client and listeners"] + Dispose --> Backoff["Capped exponential backoff plus jitter"] + Backoff --> Resolve + Ready --> Stop["SIGINT or SIGTERM"] + Stop --> DisposeDone["Dispose client and management session"] +``` + +Every application-level recovery creates a new SDK client. Its immutable port +filter is registered synchronously before `connect()` runs. The previous client +is disposed before backoff. This prevents reconnect paths from bypassing the +allowlist or leaving orphan listeners. On operator shutdown, the relay also +cancels the SDK connection token and disposes both clients. A short entrypoint +fallback exits the process only if the upstream SDK leaves an idle transport +handle after reporting disposal complete. + +## RPC transaction + +1. Connect to the selected local host and port with a finite timeout. +2. Feed bytes into a MessagePack unpacker with explicit frame, string, binary, + array, map, and extension limits. +3. Require a map containing a bounded software version and protocol version 5. +4. Echo the advertised software version in the protocol handshake. +5. Generate a random positive request ID and send `spawn` with a separate command + and argv, optional cwd, and an empty-by-default environment map. +6. Require exactly three unique stream IDs: stdin, stdout, and stderr. Close stdin + explicitly and reject data on that stream. +7. Reject data before stream advertisement, unknown IDs, duplicate transitions, + non-byte payloads, oversized output, malformed responses, and invalid exits. +8. Close the socket on success, timeout, limit violation, protocol failure, or + local interruption. + +The client never interprets a command string. Calling `/bin/sh -lc` is an explicit +operator choice and moves shell parsing into that named remote process. + +## Security model + +### Assets protected + +- GitHub access tokens and credential-store contents. +- Tunnel metadata, remote host identity, and private service topology. +- The client host's network exposure and local account boundary. +- The integrity of remote command arguments and environment values. +- Client memory, terminal state, and availability when consuming remote frames. + +### Trust boundaries + +- GitHub and Microsoft authentication services are external trusted dependencies. +- The tunnel service and SDK transport are trusted for authenticated transport, + not for local port policy. +- The remote RPC endpoint and its output are untrusted inputs. +- Loopback is a single-host boundary, not a substitute for local user isolation. +- Service configuration is readable by its owning account and must contain no token. + +### Enforced invariants + +- Listener configuration is a constant `127.0.0.1`; the SDK may mirror accepted + ports on `::1`, and no wildcard override exists. +- Every allowed port is an explicit integer from 1 through 65535. +- Every rejected port-forward event is cancelled before local acceptance. +- Tokens do not enter argv, serialized config, fixtures, logs, or generic errors. +- GitHub token environment variables are deleted before asynchronous work. +- No child process is launched after a token is present in relay memory. +- Protocol resource use is bounded before remote bytes are emitted locally. +- Terminal controls are escaped unless the operator opts into raw output. + +## Deployment workflow + +### Host + +1. Install the standalone CLI at a stable path. +2. Authenticate and register the friendly name. +3. Install the tunnel service. +4. Verify restart survival and outbound network policy. + +### Client + +1. Check out a reviewed tag or commit. +2. Run `npm ci` and install the Python package in a dedicated virtual environment. +3. Authenticate `gh` under the service account that owns or can access the tunnel. +4. Store only non-secret selector and allowlist settings in a mode-0600 file. +5. Run the relay in the foreground and verify loopback-only listeners. +6. Execute a read-only RPC health command. +7. Install the systemd or launchd service and repeat the health check after reboot. + +## Update and rollback + +### Host CLI + +1. Record the working CLI version and current service state. +2. Stop the host service during a planned window. +3. Replace the standalone binary at its stable path. +4. Start the service and verify tunnel registration plus a client connection. +5. If verification fails, restore the previous binary and restart the service. + +### Client project + +1. Review the changelog, dependency updates, protocol compatibility, and CI results. +2. Stop the relay service. +3. Preserve the current checkout or tag as the rollback target. +4. Install the new lockfiles in a clean environment and run all local checks. +5. Start the relay and perform a read-only RPC health command. +6. Roll back the checkout and dependency install if listeners, authentication, or + the RPC health command fail. + +## Health checks + +Positive checks are required; a process merely existing is not enough. + +```bash +# Host: inspect supported commands and the installed binary. +code --version +code tunnel --help + +# Client: relay help loads the real SDK dependency graph. +node bin/remote-ssh-tunnel-relay.js --help + +# Client: confirm listeners are loopback-only. +lsof -nP -iTCP -sTCP:LISTEN | grep -E '127\.0\.0\.1|\[::1\]' + +# Client: run a harmless command through the configured RPC port. +.venv/bin/remote-ssh-tunnel-rpc --port 45001 -- /usr/bin/id -un +``` + +A production monitor should distinguish: + +- host tunnel absent; +- management authentication rejected; +- tunnel label missing or ambiguous; +- relay disconnected but retrying; +- expected loopback port absent; +- TCP port open but RPC protocol unhealthy; +- RPC healthy but the requested remote dependency unhealthy. + +## Failure and recovery matrix + +| Failure | Expected behavior | Operator action if persistent | +| --- | --- | --- | +| Host power loss | host tunnel service returns after boot; client reconnects | inspect host service and outbound network | +| Client power loss | relay service starts after boot and resolves tunnel again | inspect service manager and GitHub CLI auth | +| Network interruption | SDK reconnects, then supervisor creates a fresh client if needed | confirm dev-tunnels endpoints are reachable | +| Expired/revoked token | relay fails authentication without logging token material | run `gh auth status`, reauthenticate, restart | +| Friendly name missing | fail closed before a relay client connects | verify host registration and exact name | +| Friendly name ambiguous | fail closed instead of choosing arbitrarily | rename or unregister the stale tunnel | +| Unexpected remote port | event is cancelled; no local listener opens | correct host exposure or client allowlist | +| RPC protocol version change | client exits with protocol error | upgrade only after compatibility tests | +| Oversized/malformed RPC output | socket closes before exceeding local bound | inspect the remote endpoint; do not raise limits blindly | + +## Decommission + +1. Stop and disable the client relay service. +2. Confirm all local forwarded listeners have closed. +3. Remove the client service file and non-secret selector configuration. +4. Run `code tunnel service uninstall` on the host. +5. Run `code tunnel unregister` if the account association should also be removed. +6. Verify the friendly tunnel no longer appears to authenticated clients. +7. Revoke credentials only when they were dedicated to this deployment or exposed. + +## Compatibility and upstream dependencies + +- Node.js: 20 and newer, continuously tested on 20, 22, and 24. +- Python: 3.11 and newer, continuously tested on 3.11 through 3.14. +- Microsoft dev-tunnels SDK: pinned to 1.3.50. +- MessagePack Python: pinned to 1.2.1. +- RPC protocol: observed version 5 only. + +The VS Code Remote RPC interface is private and can change without semantic +versioning for this project. Upstream drift must be handled as a compatibility +change with fixtures, tests, live read-only verification, and a changelog entry. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9fd83c9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2110 @@ +{ + "name": "remote-ssh-tunnel", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "remote-ssh-tunnel", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@microsoft/dev-tunnels-connections": "1.3.50", + "@microsoft/dev-tunnels-contracts": "1.3.50", + "@microsoft/dev-tunnels-management": "1.3.50", + "vscode-jsonrpc": "4.0.0" + }, + "bin": { + "remote-ssh-tunnel-relay": "bin/remote-ssh-tunnel-relay.js" + }, + "devDependencies": { + "markdownlint-cli2": "0.23.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@microsoft/dev-tunnels-connections": { + "version": "1.3.50", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-connections/-/dev-tunnels-connections-1.3.50.tgz", + "integrity": "sha512-L3vUE7jiW4tzx1D+sEsuCW5UKK3CjYFIxOtjZF/MN8ZCM2a2MVtIijctjU+/Y6Gi+ohvrOZKoqSZFRD12bpAgA==", + "license": "MIT", + "dependencies": { + "@microsoft/dev-tunnels-contracts": "1.3.50", + "@microsoft/dev-tunnels-management": "1.3.50", + "await-semaphore": "^0.1.3", + "buffer": "^5.2.1", + "debug": "^4.1.1", + "es5-ext": "0.10.64", + "uuid": "^3.3.3", + "vscode-jsonrpc": "^4.0.0", + "websocket": "^1.0.28" + }, + "peerDependencies": { + "@microsoft/dev-tunnels-ssh": "^3.12.29", + "@microsoft/dev-tunnels-ssh-tcp": "^3.12.29" + } + }, + "node_modules/@microsoft/dev-tunnels-contracts": { + "version": "1.3.50", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-contracts/-/dev-tunnels-contracts-1.3.50.tgz", + "integrity": "sha512-R4G/h939dL3UOui/69cKmRNZVf+3IpO6bMWxOgt4NYjF2IvWaSWBoc+u2AdZuZi+0ZHatWXvjoCDLNrV4pUyLQ==", + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "debug": "^4.1.1", + "vscode-jsonrpc": "^4.0.0" + } + }, + "node_modules/@microsoft/dev-tunnels-management": { + "version": "1.3.50", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-management/-/dev-tunnels-management-1.3.50.tgz", + "integrity": "sha512-sWK0CrBcmiNyeb3HztocR+Gd5ROfKjyixhxfSJ+TlGIj9Y4i3DggBDFZeaKGql5LbJN5+v13tOggF2vhn8OEfA==", + "license": "MIT", + "dependencies": { + "@microsoft/dev-tunnels-contracts": "1.3.50", + "axios": "^1.8.4", + "buffer": "^5.2.1", + "debug": "^4.1.1", + "vscode-jsonrpc": "^4.0.0" + } + }, + "node_modules/@microsoft/dev-tunnels-ssh": { + "version": "3.12.36", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-ssh/-/dev-tunnels-ssh-3.12.36.tgz", + "integrity": "sha512-bpiXzkGEIDVTRIzKys4x+zEawOlQUtEgrddjCl42601diMs8nsPBCXDL+ux6mkrDzDJzVaaGsafVs9CMRVXSkA==", + "license": "MIT", + "peer": true, + "dependencies": { + "buffer": "^5.2.1", + "debug": "^4.1.1", + "diffie-hellman": "^5.0.3", + "vscode-jsonrpc": "^4.0.0" + } + }, + "node_modules/@microsoft/dev-tunnels-ssh-tcp": { + "version": "3.12.36", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-ssh-tcp/-/dev-tunnels-ssh-tcp-3.12.36.tgz", + "integrity": "sha512-cMEzZfStplE51MukNzpxrKpawdPGCC8EuKU/XmWhQSfPr5MhzUbWfkAXKwYaCyKg7FQFSbMdc7RX/xj8XjRyBA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@microsoft/dev-tunnels-ssh": "~3.12" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/await-semaphore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/await-semaphore/-/await-semaphore-0.1.3.tgz", + "integrity": "sha512-d1W2aNSYcz/sxYO4pMGX9vq65qOTu0P800epMud+6cYYX0QcT7zyqcxec3VWzpgvdXo57UWmVbZpLMjX2m1I7Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT", + "peer": true + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT", + "peer": true + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "license": "MIT", + "peer": true, + "dependencies": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz", + "integrity": "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.5", + "is-path-inside": "^4.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.0.tgz", + "integrity": "sha512-YeLUMlvR4Ou1B119LIaM0r65JvbOBooJDc9yEu0dClb/uSC5P4FrLU8OCCz/HXWvtPoIrR0dRzABTjo1sTN9Bw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.1", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdownlint": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.41.0.tgz", + "integrity": "sha512-xMUI3ChBuRuxuLF4ENvCZyS8z/+Jly1coUcZwErKLIB3sDj7ojpaTBa1e9YVPhSN4jGEIjYGQCldbTJS/hqS+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark": "4.0.2", + "micromark-core-commonmark": "2.0.3", + "micromark-extension-directive": "4.0.0", + "micromark-extension-gfm-autolink-literal": "2.1.0", + "micromark-extension-gfm-footnote": "2.1.0", + "micromark-extension-gfm-table": "2.1.1", + "micromark-extension-math": "3.1.0", + "micromark-util-types": "2.0.2", + "string-width": "8.2.1" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.23.0.tgz", + "integrity": "sha512-1nmgQmU/ZTMRVwYCDs7i1HI3zfBISnT2NNRv+9V01oOLZbAtqL+a7tldpPhBWBVBten3FqhMCGV6EUh9McqutQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "globby": "16.2.0", + "js-yaml": "5.2.0", + "jsonc-parser": "3.3.1", + "jsonpointer": "5.0.1", + "markdown-it": "14.2.0", + "markdownlint": "0.41.0", + "markdownlint-cli2-formatter-default": "0.0.6", + "micromatch": "4.0.8", + "smol-toml": "1.7.0" + }, + "bin": { + "markdownlint-cli2": "markdownlint-cli2-bin.mjs" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2-formatter-default": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/markdownlint-cli2-formatter-default/-/markdownlint-cli2-formatter-default-0.0.6.tgz", + "integrity": "sha512-VVDGKsq9sgzu378swJ0fcHfSicUnMxnL8gnLm/Q4J/xsNJ4e5bA6lvAz7PCzIl0/No0lHyaWdqVD2jotxOSFMQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + }, + "peerDependencies": { + "markdownlint-cli2": ">=0.0.4" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + }, + "bin": { + "miller-rabin": "bin/miller-rabin" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/smol-toml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", + "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz", + "integrity": "sha512-perEnXQdQOJMTDFNv+UF3h1Y0z4iSiaN9jIlb0OqIYgosPCZGYh/MCUlkFtV2668PL69lRDO32hmvL2yiidUYg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0 || >=10.0.0" + } + }, + "node_modules/websocket": { + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", + "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==", + "license": "Apache-2.0", + "dependencies": { + "bufferutil": "^4.0.1", + "debug": "^2.2.0", + "es5-ext": "^0.10.63", + "typedarray-to-buffer": "^3.1.5", + "utf-8-validate": "^5.0.2", + "yaeti": "^0.0.6" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/websocket/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/websocket/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/yaeti": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", + "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "engines": { + "node": ">=0.10.32" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9e7cc8e --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "remote-ssh-tunnel", + "version": "0.1.0", + "description": "Loopback-only relay and RPC tools for an existing VS Code Remote tunnel", + "type": "module", + "private": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "bin": { + "remote-ssh-tunnel-relay": "bin/remote-ssh-tunnel-relay.js" + }, + "scripts": { + "check": "node --check bin/remote-ssh-tunnel-relay.js && node --test tests/node/*.test.js", + "lint:markdown": "markdownlint-cli2 '**/*.md' '#node_modules' '#.venv'", + "test": "node --test tests/node/*.test.js" + }, + "dependencies": { + "@microsoft/dev-tunnels-connections": "1.3.50", + "@microsoft/dev-tunnels-contracts": "1.3.50", + "@microsoft/dev-tunnels-management": "1.3.50", + "vscode-jsonrpc": "4.0.0" + }, + "overrides": { + "uuid": "11.1.1" + }, + "devDependencies": { + "markdownlint-cli2": "0.23.0" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..53cc0c1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["setuptools==83.0.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "remote-ssh-tunnel-rpc" +version = "0.1.0" +description = "A bounded MessagePack RPC client for an existing VS Code Remote tunnel" +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +authors = [{ name = "John Osumi" }] +dependencies = ["msgpack==1.2.1"] + +[project.scripts] +remote-ssh-tunnel-rpc = "remote_ssh_tunnel.cli:main" + +[project.optional-dependencies] +dev = [ + "pytest==9.1.1", + "ruff==0.15.21", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +addopts = "-ra --strict-config --strict-markers" +testpaths = ["tests/python"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "S", "UP"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5e4913b --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +msgpack==1.2.1 diff --git a/scripts/validate_repository.py b/scripts/validate_repository.py new file mode 100755 index 0000000..3833b3c --- /dev/null +++ b/scripts/validate_repository.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import hashlib +import json +import plistlib +import re +import shutil +import stat +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +IGNORED_PARTS = {".git", ".venv", "node_modules", "__pycache__"} +ACTION_PIN = re.compile(r"^\s*-?\s*uses:\s*([^\s#]+)") +FULL_SHA = re.compile(r"^[0-9a-f]{40}$") +SOURCE_REVISION = "1803940623da0ba648084b5ba0b1265b2b854ae4" +CC_LICENSE_SHA256 = "fd3a263fe19ed8faa9068b43abaebafc02c77897b0c6fc09abc04bb592e5f16e" + + +def repository_files() -> list[Path]: + git = shutil.which("git") + if git is None: + raise RuntimeError("git is required for repository validation") + completed = subprocess.run( # noqa: S603 - executable is resolved; arguments are fixed. + [git, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], + cwd=ROOT, + check=True, + capture_output=True, + ) + return [ + ROOT / relative.decode() + for relative in completed.stdout.split(b"\0") + if relative and not any(part in IGNORED_PARTS for part in Path(relative.decode()).parts) + ] + + +def check_required_files(errors: list[str]) -> None: + required = [ + "LICENSE", + "README.md", + "SECURITY.md", + "THIRD_PARTY_NOTICES.md", + "package-lock.json", + "pyproject.toml", + ".github/workflows/ci.yml", + ".github/workflows/codeql.yml", + ".github/workflows/secret-scan.yml", + "assets/vscode-interface/LICENSE", + "assets/vscode-interface/SOURCE.json", + ] + for relative in required: + if not (ROOT / relative).is_file(): + errors.append(f"missing required file: {relative}") + + +def check_json(errors: list[str]) -> None: + for path in repository_files(): + if path.suffix == ".json": + try: + json.loads(path.read_text(encoding="utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + errors.append(f"invalid JSON: {path.relative_to(ROOT)}: {error}") + + +def check_action_pins(errors: list[str]) -> None: + for path in (ROOT / ".github/workflows").glob("*.yml"): + for line_number, line in enumerate(path.read_text().splitlines(), start=1): + match = ACTION_PIN.match(line) + if not match: + continue + reference = match.group(1) + if reference.startswith("./") or reference.startswith("docker://"): + continue + if "@" not in reference or not FULL_SHA.fullmatch(reference.rsplit("@", 1)[1]): + errors.append( + f"mutable action reference: {path.relative_to(ROOT)}:{line_number}: {reference}" + ) + + +def check_attribution_boundary(errors: list[str]) -> None: + source_path = ROOT / "assets/vscode-interface/SOURCE.json" + license_path = ROOT / "assets/vscode-interface/LICENSE" + try: + source = json.loads(source_path.read_text()) + except (OSError, json.JSONDecodeError): + return + + if source.get("source_revision") != SOURCE_REVISION: + errors.append("VS Code asset source revision is not the approved pinned commit") + if source.get("license") != "CC-BY-4.0" or source.get("modified") is not True: + errors.append("VS Code asset attribution must declare CC-BY-4.0 and modifications") + if license_path.is_file(): + digest = hashlib.sha256(license_path.read_bytes()).hexdigest() + if digest != CC_LICENSE_SHA256: + errors.append("CC-BY-4.0 license does not match the pinned upstream license") + + forbidden_suffixes = {".vsix", ".exe", ".dll", ".dylib", ".so", ".node"} + asset_root = ROOT / "assets/vscode-interface" + for path in asset_root.rglob("*"): + if path.is_symlink(): + errors.append(f"symlink is not allowed in attributed assets: {path.relative_to(ROOT)}") + if path.is_file() and path.suffix.lower() in forbidden_suffixes: + errors.append(f"binary is not allowed in attributed assets: {path.relative_to(ROOT)}") + + +def check_repository_sanitation(errors: list[str]) -> None: + forbidden_names = {".DS_Store", ".env"} + private_path = re.compile(r"/(?:Users|home)/(?!CHANGE_ME(?:/|$))[^/\s]+/") + token_pattern = re.compile(r"(?:ghp|github_pat)_[A-Za-z0-9_]{20,}") + + for path in repository_files(): + relative = path.relative_to(ROOT) + if path.name in forbidden_names: + errors.append(f"private/generated file is tracked: {relative}") + if path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf"}: + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + errors.append(f"unexpected binary file: {relative}") + continue + if token_pattern.search(text): + errors.append(f"token-shaped value found: {relative}") + if private_path.search(text): + errors.append(f"private absolute path found: {relative}") + if ("you" + "tube") in text.lower(): + errors.append(f"unrelated project reference found: {relative}") + + +def check_runtime_contract(errors: list[str]) -> None: + package = json.loads((ROOT / "package.json").read_text()) + lock = json.loads((ROOT / "package-lock.json").read_text()) + if package.get("version") != "0.1.0" or lock.get("version") != "0.1.0": + errors.append("package.json and package-lock.json versions must match 0.1.0") + if package.get("overrides", {}).get("uuid") != "11.1.1": + errors.append("the audited uuid compatibility override is missing") + + relay = (ROOT / "src/relay/config.js").read_text() + if 'export const LOOPBACK_HOST = "127.0.0.1";' not in relay: + errors.append("relay loopback invariant is missing") + + binary = ROOT / "bin/remote-ssh-tunnel-relay.js" + if binary.is_file() and not binary.stat().st_mode & stat.S_IXUSR: + errors.append("relay executable is not marked executable") + + requirement = (ROOT / "requirements.txt").read_text().strip() + if requirement != "msgpack==1.2.1": + errors.append("requirements.txt must pin the audited msgpack runtime") + + +def check_plist(errors: list[str]) -> None: + path = ROOT / "deploy/launchd/com.remote-ssh-tunnel.relay.plist.example" + try: + with path.open("rb") as stream: + plistlib.load(stream) + except (OSError, plistlib.InvalidFileException) as error: + errors.append(f"invalid launchd plist: {error}") + + +def main() -> int: + errors: list[str] = [] + check_required_files(errors) + check_json(errors) + check_action_pins(errors) + check_attribution_boundary(errors) + check_repository_sanitation(errors) + check_runtime_contract(errors) + check_plist(errors) + + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + print("repository validation passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/relay/config.js b/src/relay/config.js new file mode 100644 index 0000000..531a396 --- /dev/null +++ b/src/relay/config.js @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT + +import { parseArgs } from "node:util"; + +import { ConfigurationError } from "./errors.js"; + +export const LOOPBACK_HOST = "127.0.0.1"; + +const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +function requireIdentifier(value, name) { + if (!value || !IDENTIFIER_PATTERN.test(value)) { + throw new ConfigurationError(`${name} must be a non-empty service identifier`); + } + return value; +} + +function parsePorts(values) { + const requested = values.flatMap((value) => String(value).split(",")); + const ports = []; + const seen = new Set(); + + for (const raw of requested) { + const value = raw.trim(); + if (!/^\d+$/.test(value)) { + throw new ConfigurationError(`Invalid port: ${value || "empty"}`); + } + const port = Number(value); + if (!Number.isSafeInteger(port) || port < 1 || port > 65535) { + throw new ConfigurationError(`Invalid port: ${value}`); + } + if (!seen.has(port)) { + seen.add(port); + ports.push(port); + } + } + + if (ports.length === 0) { + throw new ConfigurationError("At least one explicit port is required"); + } + return ports; +} + +export function parseRelayConfig(argv, env = process.env) { + let values; + try { + ({ values } = parseArgs({ + args: argv, + strict: true, + allowPositionals: false, + options: { + "tunnel-name": { type: "string" }, + "tunnel-id": { type: "string" }, + "cluster-id": { type: "string" }, + port: { type: "string", multiple: true }, + "github-user": { type: "string" }, + "host-id": { type: "string" }, + help: { type: "boolean", short: "h", default: false }, + version: { type: "boolean", short: "V", default: false }, + }, + })); + } catch (error) { + throw new ConfigurationError(error.message); + } + + if (values.help || values.version) { + return { help: values.help, version: values.version }; + } + + const rawTunnelName = values["tunnel-name"] ?? env.REMOTE_SSH_TUNNEL_NAME; + const rawTunnelId = values["tunnel-id"] ?? env.REMOTE_SSH_TUNNEL_ID; + const rawClusterId = values["cluster-id"] ?? env.REMOTE_SSH_TUNNEL_CLUSTER_ID; + if (rawTunnelName && (rawTunnelId || rawClusterId)) { + throw new ConfigurationError( + "Select a tunnel using either its friendly name or its tunnel ID and cluster ID", + ); + } + const tunnelName = rawTunnelName + ? requireIdentifier(rawTunnelName, "Tunnel name") + : undefined; + const tunnelId = tunnelName ? undefined : requireIdentifier(rawTunnelId, "Tunnel ID"); + const clusterId = tunnelName + ? undefined + : requireIdentifier(rawClusterId, "Cluster ID"); + const portValues = values.port ?? + (env.REMOTE_SSH_TUNNEL_PORTS ? [env.REMOTE_SSH_TUNNEL_PORTS] : []); + + return Object.freeze({ + tunnelId, + clusterId, + tunnelName, + ports: Object.freeze(parsePorts(portValues)), + githubUser: values["github-user"] ?? env.REMOTE_SSH_TUNNEL_GITHUB_USER, + hostId: values["host-id"] ?? env.REMOTE_SSH_TUNNEL_HOST_ID, + localForwardingHostAddress: LOOPBACK_HOST, + }); +} + +export function relayHelp() { + return `Usage: remote-ssh-tunnel-relay [options] + +Required settings (CLI or environment): + --tunnel-name NAME REMOTE_SSH_TUNNEL_NAME (recommended) + or both: + --tunnel-id ID REMOTE_SSH_TUNNEL_ID + --cluster-id ID REMOTE_SSH_TUNNEL_CLUSTER_ID + --port PORT REMOTE_SSH_TUNNEL_PORTS (comma-separated) + +Optional: + --github-user USER GitHub CLI account to use + --host-id ID Select one host when a tunnel has multiple hosts + -h, --help Show help + -V, --version Show version + +Authentication is read from GITHUB_TOKEN/GH_TOKEN and scrubbed immediately, +or obtained from the GitHub CLI credential store. The relay uses local loopback +(127.0.0.1, with an SDK-managed ::1 mirror where available) and rejects every +port outside the explicit allowlist.`; +} diff --git a/src/relay/errors.js b/src/relay/errors.js new file mode 100644 index 0000000..dbd2c33 --- /dev/null +++ b/src/relay/errors.js @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT + +export class ConfigurationError extends Error { + constructor(message) { + super(message); + this.name = "ConfigurationError"; + this.retryable = false; + } +} + +export class AuthenticationError extends Error { + constructor(message = "GitHub authentication is unavailable") { + super(message); + this.name = "AuthenticationError"; + this.retryable = false; + } +} + +export class RelayConnectionError extends Error { + constructor({ retryable = true } = {}) { + super(retryable ? "The relay connection ended" : "The relay request was rejected"); + this.name = "RelayConnectionError"; + this.retryable = retryable; + } +} + +export function isRetryableRelayError(error) { + if (typeof error?.retryable === "boolean") { + return error.retryable; + } + + const status = error?.response?.status ?? error?.statusCode ?? error?.status; + if ([400, 401, 403, 404].includes(status)) { + return false; + } + + return true; +} + +export function publicErrorCategory(error) { + if (error instanceof ConfigurationError) { + return "configuration"; + } + if (error instanceof AuthenticationError) { + return "authentication"; + } + if (isRetryableRelayError(error)) { + return "transient_connection"; + } + return "relay_rejected"; +} diff --git a/src/relay/github-token.js b/src/relay/github-token.js new file mode 100644 index 0000000..3522278 --- /dev/null +++ b/src/relay/github-token.js @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT + +import { execFileSync } from "node:child_process"; + +import { AuthenticationError } from "./errors.js"; + +export function acquireGitHubToken({ + env = process.env, + githubUser, + execFileSyncImpl = execFileSync, +} = {}) { + const inheritedToken = githubUser + ? undefined + : env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim(); + delete env.GITHUB_TOKEN; + delete env.GH_TOKEN; + + if (inheritedToken) { + return inheritedToken; + } + + const childEnvironment = { ...env }; + delete childEnvironment.GITHUB_TOKEN; + delete childEnvironment.GH_TOKEN; + const args = ["auth", "token"]; + if (githubUser) { + args.push("--user", githubUser); + } + + try { + const token = execFileSyncImpl("gh", args, { + encoding: "utf8", + env: childEnvironment, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + if (!token) { + throw new AuthenticationError(); + } + return token; + } catch { + throw new AuthenticationError(); + } +} diff --git a/src/relay/logger.js b/src/relay/logger.js new file mode 100644 index 0000000..68d78e6 --- /dev/null +++ b/src/relay/logger.js @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT + +export function createLogger(output = process.stderr) { + return (level, event, fields = {}) => { + const safe = { + timestamp: new Date().toISOString(), + level, + event, + }; + + for (const key of ["attempt", "delay_ms", "status", "ports", "category"]) { + if (Object.hasOwn(fields, key)) { + safe[key] = fields[key]; + } + } + output.write(`${JSON.stringify(safe)}\n`); + }; +} diff --git a/src/relay/sdk-client.js b/src/relay/sdk-client.js new file mode 100644 index 0000000..6c71522 --- /dev/null +++ b/src/relay/sdk-client.js @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MIT + +import connections from "@microsoft/dev-tunnels-connections"; +import contracts from "@microsoft/dev-tunnels-contracts"; +import jsonrpc from "vscode-jsonrpc"; + +import { ConfigurationError, RelayConnectionError, isRetryableRelayError } from "./errors.js"; + +const { ConnectionStatus, TunnelRelayTunnelClient } = connections; +const { TunnelAccessScopes } = contracts; +const { CancellationTokenSource } = jsonrpc; + +const TUNNEL_REQUEST_OPTIONS = Object.freeze({ + includePorts: true, + tokenScopes: [TunnelAccessScopes.Connect], +}); + +export async function resolveTunnel(managementClient, tunnelSelector) { + if (tunnelSelector.tunnelName) { + const matches = await managementClient.listTunnels(undefined, undefined, { + labels: [tunnelSelector.tunnelName, "vscode-server-launcher"], + requireAllLabels: true, + limit: 2, + }); + if (matches.length !== 1) { + throw new ConfigurationError( + "A friendly tunnel name must resolve to exactly one VS Code tunnel", + ); + } + return managementClient.getTunnel(matches[0], TUNNEL_REQUEST_OPTIONS); + } + + return managementClient.getTunnel( + { + tunnelId: tunnelSelector.tunnelId, + clusterId: tunnelSelector.clusterId, + }, + TUNNEL_REQUEST_OPTIONS, + ); +} + +export async function connectWithAbort( + client, + tunnel, + options, + signal, + dispose = () => client.dispose(), + cancellationSource = new CancellationTokenSource(), +) { + if (signal.aborted) { + cancellationSource.cancel(); + await dispose(); + return; + } + + let resolveAbort; + const aborted = new Promise((resolve) => { + resolveAbort = resolve; + }); + const onAbort = () => { + cancellationSource.cancel(); + resolveAbort(Promise.resolve(dispose()).catch(() => {})); + }; + signal.addEventListener("abort", onAbort, { once: true }); + + const connected = Promise.resolve() + .then(() => client.connect(tunnel, options, cancellationSource.token)) + .then( + () => ({ type: "connected" }), + (error) => ({ type: "error", error }), + ); + + try { + const outcome = await Promise.race([ + connected, + aborted.then(() => ({ type: "aborted" })), + ]); + if (outcome.type === "error" && !signal.aborted) { + throw outcome.error; + } + } finally { + signal.removeEventListener("abort", onAbort); + } +} + +function waitForFinalDisconnect(client) { + let finish; + const disconnected = new Promise((resolve) => { + finish = resolve; + }); + + const subscription = client.connectionStatusChanged((event) => { + if (event.status !== ConnectionStatus.Disconnected) { + return; + } + const retryable = event.disconnectError + ? isRetryableRelayError(event.disconnectError) + : true; + finish(new RelayConnectionError({ retryable })); + }); + + return { disconnected, subscription }; +} + +export async function createSdkRelayClient({ + managementClient, + tunnelSelector, + localForwardingHostAddress, + hostId, + onStatus = () => {}, +}) { + let tunnel; + try { + tunnel = await resolveTunnel(managementClient, tunnelSelector); + } catch (error) { + if (!isRetryableRelayError(error)) { + error.retryable = false; + } + throw error; + } + + if (!tunnel) { + throw new ConfigurationError("The requested tunnel was not found"); + } + + const client = new TunnelRelayTunnelClient(managementClient, () => {}); + client.acceptLocalConnectionsForForwardedPorts = true; + client.localForwardingHostAddress = localForwardingHostAddress; + const finalDisconnect = waitForFinalDisconnect(client); + const sdkCancellationSource = new CancellationTokenSource(); + let cancellationRequested = false; + const cancellationSource = { + token: sdkCancellationSource.token, + cancel() { + if (!cancellationRequested) { + cancellationRequested = true; + sdkCancellationSource.cancel(); + } + }, + }; + const statusSubscription = client.connectionStatusChanged((event) => { + onStatus(event.status); + }); + let disposed = false; + const disposeOnce = async () => { + if (disposed) { + return; + } + disposed = true; + cancellationSource.cancel(); + finalDisconnect.subscription.dispose(); + statusSubscription.dispose(); + await client.dispose(); + sdkCancellationSource.dispose(); + }; + + return { + portForwarding: client.portForwarding, + async connect(_reference, signal) { + if (signal.aborted) { + return; + } + await connectWithAbort( + client, + tunnel, + { + enableRetry: true, + enableReconnect: true, + hostId, + keepAliveIntervalInSeconds: 30, + }, + signal, + disposeOnce, + cancellationSource, + ); + }, + async waitForDisconnect(signal) { + if (signal.aborted) { + return; + } + const outcome = await new Promise((resolve) => { + const abort = () => resolve(); + signal.addEventListener("abort", abort, { once: true }); + finalDisconnect.disconnected.then(resolve).finally(() => { + signal.removeEventListener("abort", abort); + }); + }); + if (outcome instanceof Error && !signal.aborted) { + throw outcome; + } + }, + async dispose() { + await disposeOnce(); + }, + }; +} diff --git a/src/relay/supervisor.js b/src/relay/supervisor.js new file mode 100644 index 0000000..2faf367 --- /dev/null +++ b/src/relay/supervisor.js @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT + +import { isRetryableRelayError } from "./errors.js"; + +function installPortFilter(client, allowedPorts) { + const immutableAllowlist = new Set(allowedPorts); + return client.portForwarding((event) => { + if (!Number.isInteger(event?.portNumber) || !immutableAllowlist.has(event.portNumber)) { + event.cancel = true; + } + }); +} + +export async function runRelayAttempt({ createClient, tunnel, allowedPorts, signal }) { + const client = await createClient(); + let subscription; + try { + subscription = installPortFilter(client, allowedPorts); + await client.connect(tunnel, signal); + await client.waitForDisconnect(signal); + } finally { + subscription?.dispose(); + await client.dispose(); + } +} + +function abortableSleep(milliseconds, signal) { + if (signal.aborted) { + return Promise.resolve(); + } + return new Promise((resolve) => { + const timer = setTimeout(finish, milliseconds); + signal.addEventListener("abort", finish, { once: true }); + + function finish() { + clearTimeout(timer); + signal.removeEventListener("abort", finish); + resolve(); + } + }); +} + +export async function superviseRelay({ + createClient, + tunnel, + allowedPorts, + signal, + sleep = abortableSleep, + random = Math.random, + onRetry = () => {}, +}) { + let failures = 0; + + while (!signal.aborted) { + try { + await runRelayAttempt({ createClient, tunnel, allowedPorts, signal }); + return; + } catch (error) { + if (signal.aborted) { + return; + } + if (!isRetryableRelayError(error)) { + throw error; + } + + failures += 1; + const ceiling = Math.min(30_000, 1_000 * 2 ** Math.min(failures - 1, 5)); + const delay = Math.round(ceiling * (0.75 + random() * 0.5)); + onRetry({ failures, delay }); + await sleep(delay, signal); + } + } +} diff --git a/src/remote_ssh_tunnel/__init__.py b/src/remote_ssh_tunnel/__init__.py new file mode 100644 index 0000000..02b481f --- /dev/null +++ b/src/remote_ssh_tunnel/__init__.py @@ -0,0 +1,5 @@ +# SPDX-License-Identifier: MIT + +"""Bounded RPC access through an existing VS Code Remote tunnel.""" + +__version__ = "0.1.0" diff --git a/src/remote_ssh_tunnel/cli.py b/src/remote_ssh_tunnel/cli.py new file mode 100644 index 0000000..11f4ad1 --- /dev/null +++ b/src/remote_ssh_tunnel/cli.py @@ -0,0 +1,153 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import argparse +import math +import os +import re +import sys +from collections.abc import Mapping, Sequence +from typing import BinaryIO, TextIO + +from .output import SafeOutputRenderer +from .rpc import OutputLimitError, ProtocolError, RemoteCommandError, RpcClient + +ENVIRONMENT_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def _port(value: str) -> int: + port = int(value) + if not 1 <= port <= 65535: + raise argparse.ArgumentTypeError("port must be from 1 to 65535") + return port + + +def _positive_float(value: str) -> float: + number = float(value) + if not math.isfinite(number) or number <= 0: + raise argparse.ArgumentTypeError("value must be positive") + return number + + +def _positive_int(value: str) -> int: + number = int(value) + if number <= 0: + raise argparse.ArgumentTypeError("value must be positive") + return number + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="remote-ssh-tunnel-rpc", + description="Run one direct command through a local VS Code tunnel RPC endpoint.", + ) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", required=True, type=_port) + parser.add_argument("--cwd") + parser.add_argument("--pass-env", action="append", default=[], metavar="NAME") + parser.add_argument("--connect-timeout", type=_positive_float, default=10.0) + parser.add_argument("--deadline", type=_positive_float, default=300.0) + parser.add_argument("--max-output-bytes", type=_positive_int, default=16 * 1024 * 1024) + parser.add_argument( + "--raw-output", + action="store_true", + help="write untrusted remote bytes unchanged; unsafe for terminals", + ) + parser.add_argument("command", nargs=argparse.REMAINDER) + parsed = parser.parse_args(argv) + if parsed.command and parsed.command[0] == "--": + parsed.command = parsed.command[1:] + if not parsed.command: + parser.error("a command is required after --") + return parsed + + +def build_environment(names: Sequence[str], source: Mapping[str, str]) -> dict[str, str]: + environment: dict[str, str] = {} + for name in names: + if not ENVIRONMENT_NAME.fullmatch(name): + raise ValueError(f"invalid environment variable name: {name}") + if name not in source: + raise ValueError(f"environment variable is not set: {name}") + environment[name] = source[name] + return environment + + +class OutputTarget: + def __init__(self, text: TextIO, binary: BinaryIO, *, raw: bool) -> None: + self.text = text + self.binary = binary + self.raw = raw + self.renderer = None if raw else SafeOutputRenderer() + + def write(self, data: bytes) -> None: + if self.raw: + self.binary.write(data) + self.binary.flush() + return + renderer = self.renderer + if renderer is None: + raise RuntimeError("safe output renderer is unavailable") + self.text.write(renderer.feed(data)) + self.text.flush() + + def finish(self) -> None: + if self.raw: + return + renderer = self.renderer + if renderer is None: + raise RuntimeError("safe output renderer is unavailable") + self.text.write(renderer.feed(b"", final=True)) + self.text.flush() + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + try: + environment = build_environment(args.pass_env, os.environ) + except ValueError as error: + print(f"configuration error: {error}", file=sys.stderr) + return 64 + + stdout = OutputTarget(sys.stdout, sys.stdout.buffer, raw=args.raw_output) + stderr = OutputTarget(sys.stderr, sys.stderr.buffer, raw=args.raw_output) + client = RpcClient( + host=args.host, + port=args.port, + connect_timeout=args.connect_timeout, + deadline=args.deadline, + max_output_bytes=args.max_output_bytes, + ) + + try: + return client.run( + command=args.command[0], + args=args.command[1:], + cwd=args.cwd, + env=environment, + stdout=stdout.write, + stderr=stderr.write, + ) + except TimeoutError: + print("RPC deadline exceeded", file=sys.stderr) + return 75 + except ProtocolError: + print("remote endpoint returned an invalid or unsupported RPC response", file=sys.stderr) + return 76 + except OutputLimitError: + print("remote output exceeded the configured limit", file=sys.stderr) + return 74 + except RemoteCommandError: + print("remote command could not be started", file=sys.stderr) + return 69 + except OSError: + print("could not connect to the local RPC endpoint", file=sys.stderr) + return 69 + finally: + stdout.finish() + stderr.finish() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/remote_ssh_tunnel/output.py b/src/remote_ssh_tunnel/output.py new file mode 100644 index 0000000..c926191 --- /dev/null +++ b/src/remote_ssh_tunnel/output.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import codecs +import unicodedata + + +class SafeOutputRenderer: + """Render remote bytes without allowing terminal control sequences.""" + + def __init__(self) -> None: + self._decoder = codecs.getincrementaldecoder("utf-8")("backslashreplace") + + def feed(self, data: bytes, *, final: bool = False) -> str: + text = self._decoder.decode(data, final=final) + rendered: list[str] = [] + for character in text: + if character in "\t\r\n": + rendered.append(character) + continue + + codepoint = ord(character) + category = unicodedata.category(character) + if category in {"Cc", "Cf"}: + if codepoint <= 0xFF: + rendered.append(f"\\x{codepoint:02x}") + elif codepoint <= 0xFFFF: + rendered.append(f"\\u{codepoint:04x}") + else: + rendered.append(f"\\U{codepoint:08x}") + continue + rendered.append(character) + return "".join(rendered) diff --git a/src/remote_ssh_tunnel/rpc.py b/src/remote_ssh_tunnel/rpc.py new file mode 100644 index 0000000..ffe98f0 --- /dev/null +++ b/src/remote_ssh_tunnel/rpc.py @@ -0,0 +1,340 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import math +import secrets +import socket +import time +from collections.abc import Callable, Sequence +from typing import Any, Protocol + +import msgpack + +SUPPORTED_PROTOCOL = 5 +DEFAULT_MAX_FRAME_BYTES = 1024 * 1024 +DEFAULT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024 + + +class SocketLike(Protocol): + def sendall(self, payload: bytes) -> None: ... + + def recv(self, size: int) -> bytes: ... + + def settimeout(self, timeout: float) -> None: ... + + def close(self) -> None: ... + + +class RpcError(Exception): + """Base class for local RPC failures.""" + + +class ProtocolError(RpcError): + """Raised when the remote endpoint violates the expected protocol.""" + + +class UnsupportedProtocolError(ProtocolError): + """Raised when the endpoint advertises an unsupported protocol version.""" + + +class OutputLimitError(RpcError): + """Raised before remote output would exceed the configured aggregate limit.""" + + +class RemoteCommandError(RpcError): + """Raised when the endpoint reports a remote spawn failure.""" + + +def _is_int(value: object) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + +def _validate_port(port: int) -> int: + if not _is_int(port) or not 1 <= port <= 65535: + raise ValueError("port must be an integer from 1 to 65535") + return port + + +class RpcClient: + def __init__( + self, + *, + host: str = "127.0.0.1", + port: int, + connect_timeout: float = 10.0, + deadline: float = 300.0, + max_frame_bytes: int = DEFAULT_MAX_FRAME_BYTES, + max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES, + socket_factory: Callable[..., SocketLike] = socket.create_connection, + request_id_factory: Callable[[], int] = lambda: secrets.randbelow(2**31 - 1) + 1, + ) -> None: + if ( + not isinstance(host, str) + or not host + or len(host) > 255 + or any(ord(character) < 0x20 for character in host) + ): + raise ValueError("host must be a non-empty address") + self.host = host + self.port = _validate_port(port) + if ( + isinstance(connect_timeout, bool) + or not isinstance(connect_timeout, (int, float)) + or not math.isfinite(connect_timeout) + or connect_timeout <= 0 + or isinstance(deadline, bool) + or not isinstance(deadline, (int, float)) + or not math.isfinite(deadline) + or deadline <= 0 + ): + raise ValueError("timeouts must be positive") + if not _is_int(max_frame_bytes) or not 1024 <= max_frame_bytes <= 16 * 1024 * 1024: + raise ValueError("max_frame_bytes must be from 1 KiB to 16 MiB") + if not _is_int(max_output_bytes) or max_output_bytes <= 0: + raise ValueError("max_output_bytes must be positive") + self.connect_timeout = connect_timeout + self.deadline = deadline + self.max_frame_bytes = max_frame_bytes + self.max_output_bytes = max_output_bytes + self.socket_factory = socket_factory + self.request_id_factory = request_id_factory + + def run( + self, + *, + command: str, + args: Sequence[str], + cwd: str | None = None, + env: dict[str, str] | None = None, + stdout: Callable[[bytes], None], + stderr: Callable[[bytes], None], + ) -> int: + if not isinstance(command, str) or not command or "\x00" in command: + raise ValueError("command must be a non-empty executable path or name") + if isinstance(args, (str, bytes)) or any( + not isinstance(argument, str) or "\x00" in argument for argument in args + ): + raise ValueError("arguments must be strings without NUL bytes") + if cwd is not None and (not isinstance(cwd, str) or "\x00" in cwd): + raise ValueError("cwd must be a string without NUL bytes") + environment = {} if env is None else dict(env) + if any( + not isinstance(key, str) + or not isinstance(value, str) + or "\x00" in key + or "\x00" in value + for key, value in environment.items() + ): + raise ValueError("environment names and values must be strings without NUL bytes") + + sock = self.socket_factory((self.host, self.port), timeout=self.connect_timeout) + unpacker = msgpack.Unpacker( + raw=False, + strict_map_key=True, + max_buffer_size=self.max_frame_bytes, + max_str_len=min(self.max_frame_bytes, 64 * 1024), + max_bin_len=self.max_frame_bytes, + max_array_len=64, + max_map_len=64, + max_ext_len=0, + ) + end_time = time.monotonic() + self.deadline + try: + hello = self._receive(sock, unpacker, end_time) + remote_version = self._validate_hello(hello) + self._send( + sock, + { + "id": None, + "method": "version", + "params": {"version": remote_version, "protocol_version": SUPPORTED_PROTOCOL}, + }, + ) + + request_id = self.request_id_factory() + if not _is_int(request_id) or not 1 <= request_id < 2**31: + raise ValueError("request_id_factory returned an invalid ID") + self._send( + sock, + { + "id": request_id, + "method": "spawn", + "params": { + "command": command, + "args": list(args), + "cwd": cwd, + "env": environment, + }, + }, + ) + return self._process_messages( + sock, + unpacker, + end_time, + request_id=request_id, + stdout=stdout, + stderr=stderr, + ) + finally: + sock.close() + + def _receive( + self, + sock: SocketLike, + unpacker: msgpack.Unpacker, + end_time: float, + ) -> object: + while True: + try: + return next(unpacker) + except StopIteration: + pass + except (msgpack.BufferFull, ValueError, TypeError, msgpack.FormatError) as error: + raise ProtocolError("invalid or oversized MessagePack frame") from error + + remaining = end_time - time.monotonic() + if remaining <= 0: + raise TimeoutError("RPC deadline exceeded") + sock.settimeout(min(remaining, 5.0)) + try: + chunk = sock.recv(64 * 1024) + except TimeoutError: + continue + if not chunk: + raise ProtocolError("RPC endpoint closed before returning a result") + try: + unpacker.feed(chunk) + except (msgpack.BufferFull, ValueError, TypeError) as error: + raise ProtocolError("invalid or oversized MessagePack frame") from error + + @staticmethod + def _send(sock: SocketLike, message: dict[str, Any]) -> None: + sock.sendall(msgpack.packb(message, use_bin_type=True)) + + @staticmethod + def _validate_hello(message: object) -> str: + if not isinstance(message, dict): + raise ProtocolError("RPC hello must be a map") + if ( + set(message) != {"id", "method", "params"} + or message.get("id") is not None + or message.get("method") != "version" + or not isinstance(message.get("params"), dict) + ): + raise ProtocolError("RPC hello has an invalid version envelope") + params = message["params"] + if set(params) != {"version", "protocol_version"}: + raise ProtocolError("RPC hello has unexpected version fields") + version = params.get("version") + protocol = params.get("protocol_version") + if ( + not isinstance(version, str) + or not version + or len(version) > 128 + or any(ord(character) < 0x20 for character in version) + ): + raise ProtocolError("RPC hello has an invalid software version") + if not _is_int(protocol): + raise ProtocolError("RPC hello has an invalid protocol version") + if protocol != SUPPORTED_PROTOCOL: + raise UnsupportedProtocolError(f"unsupported RPC protocol version: {protocol}") + return version + + def _process_messages( + self, + sock: SocketLike, + unpacker: msgpack.Unpacker, + end_time: float, + *, + request_id: int, + stdout: Callable[[bytes], None], + stderr: Callable[[bytes], None], + ) -> int: + streams: tuple[int, int, int] | None = None + ended: set[int] = set() + output_bytes = 0 + + while True: + message = self._receive(sock, unpacker, end_time) + if not isinstance(message, dict): + raise ProtocolError("RPC message must be a map") + + if message.get("id") == request_id: + if "error" in message: + raise RemoteCommandError("remote command could not be started") + if "result" not in message: + raise ProtocolError("RPC response is missing a result") + if streams is None or not {streams[1], streams[2]}.issubset(ended): + raise ProtocolError("RPC response arrived before output streams ended") + return self._parse_exit_code(message["result"]) + + if set(message) != {"id", "method", "params"} or message.get("id") is not None: + raise ProtocolError("RPC notification has an invalid envelope") + method = message.get("method") + params = message.get("params") + if not isinstance(method, str) or not isinstance(params, dict): + raise ProtocolError("RPC notification has an invalid shape") + + if method == "streams_started": + if streams is not None: + raise ProtocolError("duplicate streams_started notification") + if set(params) != {"for_request_id", "stream_ids"}: + raise ProtocolError("streams_started has unexpected fields") + if params.get("for_request_id") != request_id: + raise ProtocolError("streams_started references another request") + raw_streams = params.get("stream_ids") + if ( + not isinstance(raw_streams, list) + or len(raw_streams) != 3 + or any(not _is_int(stream_id) or stream_id < 0 for stream_id in raw_streams) + or len(set(raw_streams)) != 3 + ): + raise ProtocolError("streams_started must advertise three unique stream IDs") + streams = tuple(raw_streams) + self._send( + sock, + {"id": None, "method": "stream_ended", "params": {"stream": streams[0]}}, + ) + continue + + if streams is None: + raise ProtocolError("stream notification arrived before streams_started") + + expected_fields = {"stream"} if method == "stream_ended" else {"stream", "segment"} + if set(params) != expected_fields: + raise ProtocolError("stream notification has unexpected fields") + stream_id = params.get("stream") + if not _is_int(stream_id) or stream_id not in streams: + raise ProtocolError("stream notification references an unknown stream") + if method == "stream_ended": + if stream_id in ended: + raise ProtocolError("duplicate stream_ended notification") + ended.add(stream_id) + continue + if method != "stream_data": + raise ProtocolError("RPC notification uses an unknown method") + if stream_id in ended: + raise ProtocolError("stream_data arrived after stream_ended") + if stream_id == streams[0]: + raise ProtocolError("remote endpoint sent data on the stdin stream") + + data = params.get("segment") + if not isinstance(data, bytes): + raise ProtocolError("stream_data payload must be bytes") + if output_bytes + len(data) > self.max_output_bytes: + raise OutputLimitError("remote output exceeded the configured limit") + output_bytes += len(data) + (stdout if stream_id == streams[1] else stderr)(data) + + @staticmethod + def _parse_exit_code(result: object) -> int: + if not isinstance(result, dict) or set(result) != {"exit_code", "message"}: + raise ProtocolError("RPC response has an invalid result shape") + message = result.get("message") + if not isinstance(message, str) or len(message) > 4096: + raise ProtocolError("RPC response has an invalid status message") + exit_code = result.get("exit_code") + if not _is_int(exit_code) or not 0 <= exit_code <= 255: + raise ProtocolError("RPC response has an invalid exit code") + return exit_code diff --git a/tests/node/cli-smoke.test.js b/tests/node/cli-smoke.test.js new file mode 100644 index 0000000..52bc92b --- /dev/null +++ b/tests/node/cli-smoke.test.js @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +test("the installed SDK graph loads through the executable", () => { + const result = spawnSync( + process.execPath, + ["bin/remote-ssh-tunnel-relay.js", "--help"], + { encoding: "utf8" }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /127\.0\.0\.1/); + assert.equal(result.stderr, ""); +}); diff --git a/tests/node/config.test.js b/tests/node/config.test.js new file mode 100644 index 0000000..ab1ba4e --- /dev/null +++ b/tests/node/config.test.js @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { LOOPBACK_HOST, parseRelayConfig } from "../../src/relay/config.js"; + +test("parses an explicit CLI allowlist and keeps the bind address on loopback", () => { + const config = parseRelayConfig( + [ + "--tunnel-id", + "example-tunnel", + "--cluster-id", + "use1", + "--port", + "2200", + "--port", + "2201", + "--port", + "2200", + ], + {}, + ); + + assert.equal(config.localForwardingHostAddress, LOOPBACK_HOST); + assert.deepEqual(config.ports, [2200, 2201]); + assert.equal(config.tunnelId, "example-tunnel"); + assert.equal(config.clusterId, "use1"); +}); + +test("reads non-secret settings from the environment", () => { + const config = parseRelayConfig([], { + REMOTE_SSH_TUNNEL_ID: "example-tunnel", + REMOTE_SSH_TUNNEL_CLUSTER_ID: "use1", + REMOTE_SSH_TUNNEL_PORTS: "2200, 2201", + REMOTE_SSH_TUNNEL_GITHUB_USER: "example-user", + }); + + assert.deepEqual(config.ports, [2200, 2201]); + assert.equal(config.githubUser, "example-user"); +}); + +test("accepts a friendly VS Code tunnel name without internal IDs", () => { + const config = parseRelayConfig( + ["--tunnel-name", "example-host", "--port", "2200"], + {}, + ); + + assert.equal(config.tunnelName, "example-host"); + assert.equal(config.tunnelId, undefined); + assert.equal(config.clusterId, undefined); +}); + +test("rejects ambiguous friendly-name and internal-ID selectors", () => { + assert.throws( + () => + parseRelayConfig( + [ + "--tunnel-name", + "example-host", + "--tunnel-id", + "example-id", + "--cluster-id", + "use1", + "--port", + "2200", + ], + {}, + ), + /either/i, + ); +}); + +for (const [name, argv] of [ + ["missing ports", ["--tunnel-id", "example", "--cluster-id", "use1"]], + ["zero port", ["--tunnel-id", "example", "--cluster-id", "use1", "--port", "0"]], + ["large port", ["--tunnel-id", "example", "--cluster-id", "use1", "--port", "65536"]], + ["non-numeric port", ["--tunnel-id", "example", "--cluster-id", "use1", "--port", "ssh"]], +]) { + test(`rejects ${name}`, () => { + assert.throws(() => parseRelayConfig(argv, {}), /port/i); + }); +} + +test("does not accept a configurable listen address", () => { + assert.throws( + () => + parseRelayConfig( + [ + "--tunnel-id", + "example", + "--cluster-id", + "use1", + "--port", + "2200", + "--listen-host", + "0.0.0.0", + ], + {}, + ), + /Unknown option/i, + ); +}); diff --git a/tests/node/github-token.test.js b/tests/node/github-token.test.js new file mode 100644 index 0000000..303d8a2 --- /dev/null +++ b/tests/node/github-token.test.js @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { acquireGitHubToken } from "../../src/relay/github-token.js"; + +test("copies and immediately scrubs inherited token variables", () => { + const env = { + GITHUB_TOKEN: "environment-secret", + GH_TOKEN: "second-secret", + PATH: "/usr/bin", + }; + let childCalled = false; + + const token = acquireGitHubToken({ + env, + execFileSyncImpl: () => { + childCalled = true; + return "unexpected"; + }, + }); + + assert.equal(token, "environment-secret"); + assert.equal(childCalled, false); + assert.equal(Object.hasOwn(env, "GITHUB_TOKEN"), false); + assert.equal(Object.hasOwn(env, "GH_TOKEN"), false); +}); + +test("uses gh once with a scrubbed child environment", () => { + const env = { PATH: "/usr/bin", GH_TOKEN: "" }; + const calls = []; + + const token = acquireGitHubToken({ + env, + githubUser: "example-user", + execFileSyncImpl: (command, args, options) => { + calls.push({ command, args, options }); + return "credential-store-token\n"; + }, + }); + + assert.equal(token, "credential-store-token"); + assert.equal(calls.length, 1); + assert.equal(calls[0].command, "gh"); + assert.deepEqual(calls[0].args, ["auth", "token", "--user", "example-user"]); + assert.equal(Object.hasOwn(calls[0].options.env, "GITHUB_TOKEN"), false); + assert.equal(Object.hasOwn(calls[0].options.env, "GH_TOKEN"), false); +}); + +test("an explicit GitHub user overrides injected environment credentials", () => { + const env = { PATH: "/usr/bin", GITHUB_TOKEN: "injected-token" }; + let childEnvironment; + + const token = acquireGitHubToken({ + env, + githubUser: "example-user", + execFileSyncImpl: (_command, _args, options) => { + childEnvironment = options.env; + return "selected-account-token\n"; + }, + }); + + assert.equal(token, "selected-account-token"); + assert.equal(Object.hasOwn(env, "GITHUB_TOKEN"), false); + assert.equal(Object.hasOwn(childEnvironment, "GITHUB_TOKEN"), false); +}); + +test("returns a fixed authentication error without child stderr", () => { + assert.throws( + () => + acquireGitHubToken({ + env: { PATH: "/usr/bin" }, + execFileSyncImpl: () => { + throw new Error("token-like-sensitive-child-output"); + }, + }), + (error) => { + assert.equal(error.name, "AuthenticationError"); + assert.equal(error.message.includes("token-like-sensitive-child-output"), false); + return true; + }, + ); +}); diff --git a/tests/node/relay.test.js b/tests/node/relay.test.js new file mode 100644 index 0000000..21d0f90 --- /dev/null +++ b/tests/node/relay.test.js @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { runRelayAttempt, superviseRelay } from "../../src/relay/supervisor.js"; + +class FakeRelayClient { + constructor({ connectError = null, actions = [] } = {}) { + this.connectError = connectError; + this.actions = actions; + this.filter = null; + } + + portForwarding(listener) { + this.actions.push("filter"); + this.filter = listener; + return { dispose() {} }; + } + + async connect() { + this.actions.push("connect"); + if (this.connectError) { + throw this.connectError; + } + } + + async waitForDisconnect() { + this.actions.push("wait"); + } + + async dispose() { + this.actions.push("dispose"); + } +} + +test("installs the immutable port filter before connecting", async () => { + const actions = []; + const client = new FakeRelayClient({ actions }); + + await runRelayAttempt({ + createClient: () => client, + tunnel: { tunnelId: "not-logged" }, + allowedPorts: new Set([2200]), + signal: AbortSignal.timeout(1000), + }); + + assert.deepEqual(actions, ["filter", "connect", "wait", "dispose"]); + + const denied = { portNumber: 22, cancel: false }; + client.filter(denied); + assert.equal(denied.cancel, true); + + const allowed = { portNumber: 2200, cancel: false }; + client.filter(allowed); + assert.equal(allowed.cancel, false); +}); + +test("creates and pre-filters a fresh client for every recovery attempt", async () => { + const attempts = []; + const transient = Object.assign(new Error("temporary"), { retryable: true }); + const clients = [ + new FakeRelayClient({ connectError: transient, actions: [] }), + new FakeRelayClient({ actions: [] }), + ]; + + await superviseRelay({ + createClient: () => { + const client = clients.shift(); + attempts.push(client); + return client; + }, + tunnel: {}, + allowedPorts: new Set([2200]), + signal: AbortSignal.timeout(1000), + sleep: async () => {}, + random: () => 0, + }); + + assert.deepEqual(attempts[0].actions, ["filter", "connect", "dispose"]); + assert.deepEqual(attempts[1].actions, ["filter", "connect", "wait", "dispose"]); + + const deniedAfterRecovery = { portNumber: 22, cancel: false }; + attempts[1].filter(deniedAfterRecovery); + assert.equal(deniedAfterRecovery.cancel, true); +}); + +test("does not retry non-transient failures", async () => { + let creations = 0; + const fatal = Object.assign(new Error("fatal"), { retryable: false }); + + await assert.rejects( + superviseRelay({ + createClient: () => { + creations += 1; + return new FakeRelayClient({ connectError: fatal }); + }, + tunnel: {}, + allowedPorts: new Set([2200]), + signal: AbortSignal.timeout(1000), + sleep: async () => {}, + }), + fatal, + ); + + assert.equal(creations, 1); +}); diff --git a/tests/node/sdk-client.test.js b/tests/node/sdk-client.test.js new file mode 100644 index 0000000..676abc9 --- /dev/null +++ b/tests/node/sdk-client.test.js @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { connectWithAbort, resolveTunnel } from "../../src/relay/sdk-client.js"; + +test("resolves one VS Code tunnel by its friendly-name label", async () => { + const expected = { tunnelId: "opaque", clusterId: "use1" }; + let receivedOptions; + const managementClient = { + async listTunnels(cluster, domain, options) { + assert.equal(cluster, undefined); + assert.equal(domain, undefined); + receivedOptions = options; + return [expected]; + }, + async getTunnel(reference, options) { + assert.equal(reference, expected); + assert.equal(options.includePorts, true); + assert.deepEqual(options.tokenScopes, ["connect"]); + return { ...expected, accessTokens: { connect: "not-a-real-token" } }; + }, + }; + + const tunnel = await resolveTunnel(managementClient, { tunnelName: "example-host" }); + + assert.equal(tunnel.tunnelId, expected.tunnelId); + assert.deepEqual(tunnel.accessTokens, { connect: "not-a-real-token" }); + assert.deepEqual(receivedOptions.labels, ["example-host", "vscode-server-launcher"]); + assert.equal(receivedOptions.requireAllLabels, true); + assert.equal(receivedOptions.limit, 2); +}); + +test("fails closed when a friendly name is missing or ambiguous", async () => { + for (const matches of [[], [{ tunnelId: "one" }, { tunnelId: "two" }]]) { + const managementClient = { + listTunnels: async () => matches, + getTunnel: async () => { + throw new Error("getTunnel must not run for an ambiguous selector"); + }, + }; + await assert.rejects( + resolveTunnel(managementClient, { tunnelName: "example-host" }), + /exactly one/i, + ); + } +}); + +test("one abort disposes and releases a pending SDK connection", async () => { + const controller = new AbortController(); + let disposed = 0; + let cancelled = 0; + let receivedCancellationToken; + const cancellationSource = { + token: { kind: "test-cancellation-token" }, + cancel() { + cancelled += 1; + }, + }; + const client = { + connect: (_tunnel, _options, cancellationToken) => { + receivedCancellationToken = cancellationToken; + return new Promise(() => {}); + }, + async dispose() { + disposed += 1; + }, + }; + + const pending = connectWithAbort( + client, + {}, + {}, + controller.signal, + () => client.dispose(), + cancellationSource, + ); + controller.abort(); + await pending; + + assert.equal(receivedCancellationToken, cancellationSource.token); + assert.equal(cancelled, 1); + assert.equal(disposed, 1); +}); diff --git a/tests/python/test_cli.py b/tests/python/test_cli.py new file mode 100644 index 0000000..3784eac --- /dev/null +++ b/tests/python/test_cli.py @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: MIT + +import pytest + +from remote_ssh_tunnel.cli import build_environment, parse_args + + +def test_parses_a_direct_command_without_a_shell() -> None: + args = parse_args(["--port", "2200", "--", "/usr/bin/id", "-un"]) + + assert args.command == ["/usr/bin/id", "-un"] + assert args.host == "127.0.0.1" + assert args.raw_output is False + + +def test_passes_only_named_environment_values() -> None: + env = build_environment(["LANG"], {"LANG": "C.UTF-8", "SECRET": "not-forwarded"}) + + assert env == {"LANG": "C.UTF-8"} + + +def test_missing_named_environment_value_is_an_error() -> None: + with pytest.raises(ValueError, match="MISSING"): + build_environment(["MISSING"], {}) + + +def test_rejects_non_finite_deadlines() -> None: + with pytest.raises(SystemExit): + parse_args(["--port", "2200", "--deadline", "nan", "--", "true"]) diff --git a/tests/python/test_output.py b/tests/python/test_output.py new file mode 100644 index 0000000..c1090f8 --- /dev/null +++ b/tests/python/test_output.py @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: MIT + +from remote_ssh_tunnel.output import SafeOutputRenderer + + +def test_escapes_terminal_controls_and_invalid_utf8() -> None: + renderer = SafeOutputRenderer() + + rendered = renderer.feed(b"before\x1b]0;owned\x07after\xff", final=True) + + assert rendered == r"before\x1b]0;owned\x07after\xff" + + +def test_preserves_text_newlines_and_split_utf8() -> None: + renderer = SafeOutputRenderer() + + first = renderer.feed(b"line\n\xe3\x81") + second = renderer.feed(b"\x82\n", final=True) + + assert first == "line\n" + assert second == "\u3042\n" + + +def test_escapes_unicode_format_controls() -> None: + renderer = SafeOutputRenderer() + + rendered = renderer.feed("left\u202eright".encode(), final=True) + + assert rendered == r"left\u202eright" diff --git a/tests/python/test_rpc.py b/tests/python/test_rpc.py new file mode 100644 index 0000000..35f1175 --- /dev/null +++ b/tests/python/test_rpc.py @@ -0,0 +1,247 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +from collections.abc import Iterable + +import msgpack +import pytest + +from remote_ssh_tunnel.rpc import ( + OutputLimitError, + ProtocolError, + RpcClient, + UnsupportedProtocolError, +) + + +class ScriptedSocket: + def __init__(self, messages: Iterable[object]) -> None: + self.chunks = [b"".join(msgpack.packb(message, use_bin_type=True) for message in messages)] + self.sent: list[object] = [] + self.closed = False + self.timeouts: list[float] = [] + + def sendall(self, payload: bytes) -> None: + self.sent.append(msgpack.unpackb(payload, raw=False)) + + def recv(self, _size: int) -> bytes: + return self.chunks.pop(0) if self.chunks else b"" + + def settimeout(self, timeout: float) -> None: + self.timeouts.append(timeout) + + def close(self) -> None: + self.closed = True + + +def scripted_client(messages: list[object], **kwargs: object) -> tuple[RpcClient, ScriptedSocket]: + sock = ScriptedSocket(messages) + client = RpcClient( + port=45001, + socket_factory=lambda *_args, **_kwargs: sock, + request_id_factory=lambda: 7, + **kwargs, + ) + return client, sock + + +def test_runs_a_direct_command_and_routes_advertised_streams() -> None: + stdout: list[bytes] = [] + stderr: list[bytes] = [] + client, sock = scripted_client( + [ + { + "id": None, + "method": "version", + "params": {"version": "1.2.3", "protocol_version": 5}, + }, + { + "id": None, + "method": "streams_started", + "params": {"for_request_id": 7, "stream_ids": [10, 11, 12]}, + }, + {"id": None, "method": "stream_data", "params": {"stream": 11, "segment": b"hello\n"}}, + { + "id": None, + "method": "stream_data", + "params": {"stream": 12, "segment": b"warning\n"}, + }, + {"id": None, "method": "stream_ended", "params": {"stream": 11}}, + {"id": None, "method": "stream_ended", "params": {"stream": 12}}, + {"id": 7, "result": {"exit_code": 0, "message": "ok"}}, + ] + ) + + exit_code = client.run( + command="/usr/bin/example", + args=["one", "two words"], + cwd=None, + env={}, + stdout=stdout.append, + stderr=stderr.append, + ) + + assert exit_code == 0 + assert stdout == [b"hello\n"] + assert stderr == [b"warning\n"] + assert sock.sent[0] == { + "id": None, + "method": "version", + "params": {"version": "1.2.3", "protocol_version": 5}, + } + assert sock.sent[1] == { + "id": 7, + "method": "spawn", + "params": { + "command": "/usr/bin/example", + "args": ["one", "two words"], + "cwd": None, + "env": {}, + }, + } + assert sock.sent[2] == {"id": None, "method": "stream_ended", "params": {"stream": 10}} + assert sock.closed is True + + +def test_fails_closed_on_an_unsupported_protocol() -> None: + client, sock = scripted_client( + [ + { + "id": None, + "method": "version", + "params": {"version": "1.2.3", "protocol_version": 6}, + } + ] + ) + + with pytest.raises(UnsupportedProtocolError): + client.run(command="true", args=[], stdout=lambda _data: None, stderr=lambda _data: None) + + assert sock.closed is True + + +def test_rejects_stream_data_before_streams_are_advertised() -> None: + client, sock = scripted_client( + [ + { + "id": None, + "method": "version", + "params": {"version": "1.2.3", "protocol_version": 5}, + }, + {"id": None, "method": "stream_data", "params": {"stream": 11, "segment": b"bad"}}, + ] + ) + + with pytest.raises(ProtocolError, match="stream"): + client.run(command="true", args=[], stdout=lambda _data: None, stderr=lambda _data: None) + + assert sock.closed is True + + +def test_rejects_duplicate_stream_start_messages() -> None: + client, _sock = scripted_client( + [ + { + "id": None, + "method": "version", + "params": {"version": "1.2.3", "protocol_version": 5}, + }, + { + "id": None, + "method": "streams_started", + "params": {"for_request_id": 7, "stream_ids": [10, 11, 12]}, + }, + { + "id": None, + "method": "streams_started", + "params": {"for_request_id": 7, "stream_ids": [20, 21, 22]}, + }, + ] + ) + + with pytest.raises(ProtocolError, match="duplicate"): + client.run(command="true", args=[], stdout=lambda _data: None, stderr=lambda _data: None) + + +def test_enforces_the_aggregate_output_limit_before_writing() -> None: + output: list[bytes] = [] + client, sock = scripted_client( + [ + { + "id": None, + "method": "version", + "params": {"version": "1.2.3", "protocol_version": 5}, + }, + { + "id": None, + "method": "streams_started", + "params": {"for_request_id": 7, "stream_ids": [10, 11, 12]}, + }, + {"id": None, "method": "stream_data", "params": {"stream": 11, "segment": b"12345"}}, + ], + max_output_bytes=4, + ) + + with pytest.raises(OutputLimitError): + client.run(command="true", args=[], stdout=output.append, stderr=output.append) + + assert output == [] + assert sock.closed is True + + +def test_rejects_non_byte_stream_payloads() -> None: + client, _sock = scripted_client( + [ + { + "id": None, + "method": "version", + "params": {"version": "1.2.3", "protocol_version": 5}, + }, + { + "id": None, + "method": "streams_started", + "params": {"for_request_id": 7, "stream_ids": [10, 11, 12]}, + }, + {"id": None, "method": "stream_data", "params": {"stream": 11, "segment": "text"}}, + ] + ) + + with pytest.raises(ProtocolError, match="bytes"): + client.run(command="true", args=[], stdout=lambda _data: None, stderr=lambda _data: None) + + +def test_rejects_streams_for_another_request() -> None: + client, _sock = scripted_client( + [ + { + "id": None, + "method": "version", + "params": {"version": "1.2.3", "protocol_version": 5}, + }, + { + "id": None, + "method": "streams_started", + "params": {"for_request_id": 8, "stream_ids": [10, 11, 12]}, + }, + ] + ) + + with pytest.raises(ProtocolError, match="request"): + client.run(command="true", args=[], stdout=lambda _data: None, stderr=lambda _data: None) + + +def test_rejects_invalid_direct_api_inputs_before_connecting() -> None: + with pytest.raises(ValueError, match="timeouts"): + RpcClient(port=45001, deadline=float("nan")) + + client, sock = scripted_client([]) + with pytest.raises(ValueError, match="cwd"): + client.run( + command="true", + args=[], + cwd="bad\x00path", + stdout=lambda _data: None, + stderr=lambda _data: None, + ) + assert sock.closed is False