diff --git a/.cursor/rules/cli-write-safety.mdc b/.cursor/rules/ess-cli-write-safety.mdc similarity index 92% rename from .cursor/rules/cli-write-safety.mdc rename to .cursor/rules/ess-cli-write-safety.mdc index 24e6f5b0..1d6608e6 100644 --- a/.cursor/rules/cli-write-safety.mdc +++ b/.cursor/rules/ess-cli-write-safety.mdc @@ -1,6 +1,6 @@ --- description: Every CLI in this repo that writes or destroys must support --dry-run, confirm by default, and accept --yes -alwaysApply: true +globs: **/tools/**, **/cli.py, **/commands/**, **/*.sh, **/__main__.py --- # CLI Write Safety @@ -46,10 +46,7 @@ Must not prompt, and must not offer `--dry-run` — there is nothing to preview. ## Where the flags live Put `--dry-run` on the group when it governs every subcommand, and `--yes` on each write -command. `keys delete` in `packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py` -shows the confirmation half: it lists the keys it will delete, prompts by default, and -accepts `--yes`. It does not yet offer `--dry-run`, and its prompt goes to stdout; do not -copy those two gaps. The complete shape: +command. The complete shape: ```python @click.option("--dry-run", is_flag=True, default=False, diff --git a/.gitignore b/.gitignore index 4d3dc490..ec5a1a6b 100644 --- a/.gitignore +++ b/.gitignore @@ -272,6 +272,9 @@ Pulumi.*.yaml # Output files output/ +# Node.js +node_modules/ + # Claude .claude/settings.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 30bc4d1e..248272bd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,9 +1,9 @@ repos: # Ruff for Python linting and formatting - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.6 + rev: v0.15.6 hooks: - - id: ruff + - id: ruff-check args: [--fix, --exit-non-zero-on-fix] exclude: ^.*/migrations/ stages: [pre-push] diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 00000000..86518bf0 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,13 @@ +- id: ess-scan + name: ess-scan (secrets, jargon, PII in ess-* items) + description: >- + Runs the essentials-sync deterministic scanners on each changed ess-* item + and fails on critical findings. Needs Node.js 22 or later; trufflehog is + used when installed. + entry: ess-scan + language: node + # Use the Node on PATH. Otherwise pre-commit skips a Node under $HOME (nvm) + # and downloads one through nodeenv, which fails on prerelease versions. + language_version: system + files: '(^|/)ess-[^/]+/|(^|/)ess/ess-[^/]+$' + require_serial: true diff --git a/skills/ess/git-tidy/LICENSE b/examples/python/ess-hello-jwt-auth-code/LICENSE similarity index 100% rename from skills/ess/git-tidy/LICENSE rename to examples/python/ess-hello-jwt-auth-code/LICENSE diff --git a/examples/python/ess-hello-jwt-auth-code/pyproject.toml b/examples/python/ess-hello-jwt-auth-code/pyproject.toml index 3b13ad4d..32dd8378 100644 --- a/examples/python/ess-hello-jwt-auth-code/pyproject.toml +++ b/examples/python/ess-hello-jwt-auth-code/pyproject.toml @@ -3,8 +3,8 @@ name = "ess-hello-jwt-auth-code" version = "0.1.0" description = "Obtain a JWT access token from any OpenID Connect provider via the OAuth 2.0 Authorization Code Flow with PKCE" readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" -license = { text = "Apache-2.0" } keywords = ["oauth2", "oidc", "jwt", "pkce", "authorization-code"] dependencies = [ "httpx>=0.28.0", @@ -18,7 +18,7 @@ ess-hello-jwt-auth-code = "ess_hello_jwt_auth_code.main:main" dev = ["ruff>=0.14.2", "pytest>=8.0.0"] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/examples/python/ess-hello-jwt-auth-code/src/ess_hello_jwt_auth_code/test_main.py b/examples/python/ess-hello-jwt-auth-code/src/ess_hello_jwt_auth_code/test_main.py index 03b67e4b..6682e578 100644 --- a/examples/python/ess-hello-jwt-auth-code/src/ess_hello_jwt_auth_code/test_main.py +++ b/examples/python/ess-hello-jwt-auth-code/src/ess_hello_jwt_auth_code/test_main.py @@ -1,4 +1,4 @@ -# ruff: noqa: S106, PLR2004 -- test credentials are not real secrets; magic numbers expected +# ruff: noqa: S106, PLR2004, B110 -- test credentials are not real secrets; magic numbers expected; server may close before response is read """Tests for the OAuth 2.0 Authorization Code Flow.""" from __future__ import annotations diff --git a/examples/python/ess-messages/.env.example b/examples/python/ess-messages/.env.example index 0d546567..f3080260 100644 --- a/examples/python/ess-messages/.env.example +++ b/examples/python/ess-messages/.env.example @@ -1,8 +1,15 @@ -# Outlook (Microsoft Graph) +# Outlook (Microsoft Graph). CLIENT_ID and TENANT_ID are required for the +# email source unless GRAPH_ACCESS_TOKEN is set; there is no default client ID. # CLIENT_ID: Microsoft Entra app registration (e.g., the "Office Desktop Apps" public client) CLIENT_ID= # TENANT_ID: your organization's Microsoft Entra (Azure AD) tenant ID TENANT_ID= +# Optional: a token from https://developer.microsoft.com/graph/graph-explorer, +# for tenants where Conditional Access blocks the device-code flow (~1h TTL) +# GRAPH_ACCESS_TOKEN= +# Optional: browser sign-in instead of device code. Requires CLIENT_ID to be an +# app registration that lists "http://localhost" as a redirect URI. +# GRAPH_INTERACTIVE_AUTH=1 # Webex OAuth integration (from https://developer.webex.com/my-apps) WEBEX_CLIENT_ID= diff --git a/skills/ess/mcp-hide-secrets/LICENSE b/examples/python/ess-messages/LICENSE similarity index 100% rename from skills/ess/mcp-hide-secrets/LICENSE rename to examples/python/ess-messages/LICENSE diff --git a/examples/python/ess-messages/README.md b/examples/python/ess-messages/README.md index e2fa835c..de144434 100644 --- a/examples/python/ess-messages/README.md +++ b/examples/python/ess-messages/README.md @@ -33,13 +33,28 @@ uv sync --all-packages ## Auth Requires credentials for one or both sources. See: -- [Outlook setup](../../../packages/python/ess-outlook/.env.example) -- `CLIENT_ID`, `TENANT_ID` +- [Outlook setup](../../../packages/python/ess-outlook/README.md#configuration) -- + `CLIENT_ID` and `TENANT_ID`, both required unless `GRAPH_ACCESS_TOKEN` is set - [Webex setup](../../../packages/python/ess-webex/.env.example) -- `WEBEX_CLIENT_ID`/`WEBEX_CLIENT_SECRET` or `WEBEX_ACCESS_TOKEN` Copy this example's `.env.example` to `.env` and fill in values, or set the variables in your shell environment. Never commit `.env` -- it is meant to hold secrets. +If Conditional Access blocks the Outlook device-code flow, set +`GRAPH_ACCESS_TOKEN` to a token from +[Graph Explorer](https://developer.microsoft.com/graph/graph-explorer) -- +see [Bring your own token](../../../packages/python/ess-outlook/README.md#bring-your-own-token). +If Outlook authentication fails, the command exits unless you pass +`--webex-only`, which skips Outlook and fetches Webex only. Within +ess-outlook, multiple auth methods are tried before failure (supplied token, +cached refresh, optional browser sign-in, then device code). + +For a permanent fix, ask your identity team for a dedicated app registration: a +single-tenant public client with delegated `Mail.ReadWrite` on Microsoft Graph and +`http://localhost` as a redirect URI. Set its Application (client) ID and Directory +(tenant) ID as `CLIENT_ID` and `TENANT_ID`. + ## Usage ```bash diff --git a/examples/python/ess-messages/pyproject.toml b/examples/python/ess-messages/pyproject.toml index 044f0f2c..ffa3271a 100644 --- a/examples/python/ess-messages/pyproject.toml +++ b/examples/python/ess-messages/pyproject.toml @@ -3,6 +3,7 @@ name = "ess-messages" version = "0.1.0" description = "Unified inbox -- aggregates email and Webex messages into Activity Streams 2.0" readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [ "activitystreams2>=0.5.0", @@ -16,7 +17,7 @@ dependencies = [ ess-messages = "ess_messages.__main__:main" [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.uv.sources] diff --git a/instructions/ess/ess-cli-write-safety.instructions.md b/instructions/ess/ess-cli-write-safety.instructions.md new file mode 100644 index 00000000..2c602f0c --- /dev/null +++ b/instructions/ess/ess-cli-write-safety.instructions.md @@ -0,0 +1,96 @@ +--- +description: Every CLI in this repo that writes or destroys must support --dry-run, confirm by default, and accept --yes +applyTo: "**/tools/**,**/cli.py,**/commands/**,**/*.sh,**/__main__.py" +--- + +# CLI Write Safety + +Applies to **every command-line tool produced in this repo**, whatever the language or +argument parser: Click groups, `argparse` scripts under `tools/`, Django management +commands, TypeScript `bin` entries, and shell scripts. + +## The three requirements + +Any command that writes remote or local state, deletes, rotates a credential, or is +otherwise not trivially reversible must have all three: + +1. **`--dry-run`** — runs the same code path, prints exactly what would change, exits 0, + and touches nothing. It is not a separate "plan" branch that can drift from the real + one; resolve the same targets and build the same payloads, then stop before the write. +2. **Confirmation by default** — print a summary of what is about to happen (counts, + target names, account/stack/environment), then prompt. Abort on anything but yes. +3. **`--yes`** (alias `-y`) — bypasses the prompt, and nothing else. It must never imply + or cancel `--dry-run`. + +## Use these exact flag names + +`--dry-run` and `--yes`. Do not invent `--apply`, `--submit`, `--commit`, `--force`, +`--no-confirm`, or `--for-real`. + +Opt-in-to-write flags look safe but are not: they make the *safe* path the one the +operator has to remember, and every tool picks a different word for it, so muscle memory +from one tool does nothing for the next. Prompting by default gives the same protection +without depending on recall. + +## Precedence and non-interactive runs + +- `--dry-run` wins if both flags are passed. +- When a **real write** would occur, never silently proceed without explicit consent. + Without a TTY, or in `--json` mode, fail and tell the operator to pass `--yes`. + `--dry-run` skips this check because nothing is written. + +## Read-only commands + +Must not prompt, and must not offer `--dry-run` — there is nothing to preview. + +## Where the flags live + +Put `--dry-run` on the group when it governs every subcommand, and `--yes` on each write +command. The complete shape: + +```python +@click.option("--dry-run", is_flag=True, default=False, + help="Show planned changes without submitting them.") +... +@click.option("--yes", is_flag=True, default=False, help="Skip the confirmation prompt.") +``` + +with a single guard called by every write path: + +```python +def _confirm_or_abort(options, account, locations, *, skip: bool) -> None: + """Require explicit consent before writing credentials.""" + if skip or options.dry_run: + return + + # Real write only; --dry-run returned above. --json has no interactive + # prompt to fall back on, so make the caller opt in rather than silently + # skipping the guard. + if options.as_json: + raise click.UsageError("--json requires --yes, which confirms the write.") + + click.confirm(f"\nSet a new password for {account} in {len(locations)} location(s)?", + abort=True, err=True) +``` + +For interactive **real writes**, prompt on **stderr** (`err=True`) so piping stdout to +a file or `jq` still shows the question. Dry-run and `--yes` paths skip the prompt, so +`--dry-run --json` without `--yes` is valid. + +`@click.confirmation_option(prompt=...)` gives you `--yes` for free and satisfies +requirements 2 and 3, but you still owe `--dry-run`. + +For `argparse`, the equivalent is `--dry-run` / `--yes` store-true flags plus an explicit +`sys.stdin.isatty()` check before `input()` for real writes (not `--dry-run`); fail with +a message to pass `--yes` if non-interactive. + +## Tests (Click / Python) + +For Click-based Python CLIs, co-locate `test_*.py` per the repo's testing convention. +Two cases at minimum, using Click's `CliRunner`: + +- `--dry-run` performs no writes (assert the client/session mock was never called). +- `--yes` skips the prompt (assert the command succeeds with no stdin). + +TypeScript, shell, and Django commands must still prove the same three requirements, +using that language's test tooling and conventions. diff --git a/instructions/ess/ess-dockerfile-runtime-copy.instructions.md b/instructions/ess/ess-dockerfile-runtime-copy.instructions.md new file mode 100644 index 00000000..976cda98 --- /dev/null +++ b/instructions/ess/ess-dockerfile-runtime-copy.instructions.md @@ -0,0 +1,29 @@ +--- +description: Copy runtime image files as root-owned and not writable, so the process cannot modify its own code (SonarQube docker:S6504) +applyTo: "**/Dockerfile,**/Dockerfile.*,**/*.dockerfile" +--- + +# Runtime image copies stay root-owned + +A container should not be able to rewrite its own application code. If the runtime user owns the files it runs, a compromised process can change them, which breaks least privilege and container immutability. SonarQube reports this as **docker:S6504**, on any `COPY` or `ADD` whose `--chown` user is not `root`. + +In the final stage, copy application files as root and drop the write bit. The runtime user can read and traverse them and cannot modify them. + +```dockerfile +# Bad — node owns the app and can rewrite it +COPY --from=build --chown=node:node /app/dist ./dist +USER node + +# Good +COPY --from=build --chown=root:root --chmod=555 /app/dist ./dist +USER node +``` + +`--chmod=555` is read and execute for everyone, so directories stay traversable and files stay readable. The same flags apply whether the runtime user is `node`, `nextjs`, or any other non-root account. + +## Rules + +- On every runtime-stage `COPY` or `ADD` of application files, set `--chown=root:root --chmod=555`. +- Do not `--chown` to a non-root user or a uid other than `0`. +- A path the process must write (cache, uploads, temp) is a separate empty directory. Do not loosen the application copy to make that path writable. +- Build-stage copies that the builder then writes, such as `package.json` before `npm ci`, stay default root and omit `--chmod=555`. Still do not `--chown` them to a non-root user. diff --git a/instructions/ess/ess-docs-example-urls.instructions.md b/instructions/ess/ess-docs-example-urls.instructions.md new file mode 100644 index 00000000..282c236f --- /dev/null +++ b/instructions/ess/ess-docs-example-urls.instructions.md @@ -0,0 +1,47 @@ +--- +description: >- + Use example.com, example.net, example.org, or example.edu, a subdomain of + one, or a name under the reserved .example, .invalid, or .test TLDs for every + illustrative host, URL, and email in code, tests, comments, and docs. +applyTo: "**" +--- + +# Example URLs + +Placeholder hosts, URLs, and email addresses use a reserved name, which no one can +register: + +| Reserved | Names | +| -------- | ----- | +| Documentation domains | `example.com`, `example.net`, `example.org`, `example.edu`, and their subdomains | +| Top-level domains | Any name under `.example`, `.invalid`, or `.test` | + +RFC 2606 reserves `example.com`, `example.net`, `example.org`, and the three top-level +domains. IANA holds all four documentation domains, and each serves a page that reads: +"This domain is for use in documentation examples without needing permission." The +top-level domains are never delegated, so names under them never resolve. + +| Placeholder for | Use | Not | +| --------------- | --- | --- | +| A remote host | `host.example.com` | `host.com`, `myhost` | +| An identity provider | `idp.example.com` | `login.vendor.com` | +| A deployed app | `app.example.com` | `my-app.awsapprunner.com` | +| A person | `ada@example.com` | an address at `x.com` or a real company | +| A second organization | `corp.example.net` | `corp.example.io` | +| A host that must never resolve | `unreachable.invalid` | `nonexistent.com` | +| A throwaway test identity | `test@example.invalid` | an address at `test.com` | + +- **Default to `example.com`.** Use `example.net`, `example.org`, or `example.edu` when an + example needs organizations that are visibly distinct. +- **Use a reserved top-level domain when its meaning helps.** `.invalid` marks a value + that must never resolve or be delivered, such as an unreachable host in a failure test + or a git identity in a throwaway repo. `.test` suits hosts on a local test network, and + `.example` reads as an illustration. +- **Only these.** Other `example.` names, such as `example.io` or `example.dev`, and + look-alikes such as `test.com`, are ordinary domains someone can register. +- **Real endpoints stay real.** A host the code actually calls (`github.com`, + `graph.microsoft.com`, `api.smith.langchain.com`) is not a placeholder; leave it. +- **Exception: a domain the code enforces.** When the code under test accepts only a + provider's domain, keep the provider suffix and make `example` the leftmost label. A + client that rejects any host outside `*.service-now.com` is tested with + `example.service-now.com`. diff --git a/instructions/ess/ess-npmrc-env.instructions.md b/instructions/ess/ess-npmrc-env.instructions.md new file mode 100644 index 00000000..b177c6ec --- /dev/null +++ b/instructions/ess/ess-npmrc-env.instructions.md @@ -0,0 +1,24 @@ +--- +description: In .npmrc, reference environment variables as ${VAR}. A trailing ? is not portable. +applyTo: "**/.npmrc,**/.npmrc.*,**/npmrc" +--- + +# npmrc environment variables + +npm replaces `${VARIABLE_NAME}` in `.npmrc` values. The text inside the braces is the environment variable name, exactly. + +```ini +//registry.example.com/:_authToken=${NPM_TOKEN} +``` + +Do not append `?`. + +npm 10, bundled with Node 20 and Node 22, has no optional form. It looks up a variable named `NPM_TOKEN?`, so exporting `NPM_TOKEN` leaves `${NPM_TOKEN?}` unexpanded and authenticated installs fail. + +```ini +//registry.example.com/:_authToken=${NPM_TOKEN?} +``` + +npm 11 and later document `?` as "substitute an empty string when unset." That is still the wrong form for a required value such as an auth token: an unset variable becomes an empty token instead of a visible unreplaced placeholder. Write `${VARIABLE_NAME}` so the same file works on npm 10 and later. + +Keep the `${...}` reference. Never paste a token into a committed `.npmrc`. Export the variable before `npm ci`. diff --git a/instructions/ess/ess-okf-docs.instructions.md b/instructions/ess/ess-okf-docs.instructions.md new file mode 100644 index 00000000..04ca4c6f --- /dev/null +++ b/instructions/ess/ess-okf-docs.instructions.md @@ -0,0 +1,19 @@ +--- +description: >- + When editing or creating markdown with OKF YAML frontmatter (type, generated, + tags, etc.), consult Context7 for the OKF v0.2 spec before guessing. +applyTo: "**/*.md" +--- + +Use the Context7 MCP for the Open Knowledge Format spec. Context7 is a +configured docs server in this repo. Do not call an `okf-spec` server; none +is defined. + +1. Call `resolve-library-id` with library name `Open Knowledge Format` unless + you already have the id `/googlecloudplatform/open-knowledge-format` + (OKF v0.2, GoogleCloudPlatform/open-knowledge-format). +2. Call `query-docs` with that library id. Keep each query to one frontmatter + concept (`type`, `title`, `description`, `tags`, `generated`). + +`type` is the only required key. Consumers must tolerate unknown types and +preserve unknown keys. diff --git a/instructions/ess/ess-shell-file-naming.instructions.md b/instructions/ess/ess-shell-file-naming.instructions.md new file mode 100644 index 00000000..dadecbe0 --- /dev/null +++ b/instructions/ess/ess-shell-file-naming.instructions.md @@ -0,0 +1,52 @@ +--- +description: Name shell scripts in kebab-case after what they do, and keep each self-test beside its script as test_.sh +applyTo: "**/*.sh" +--- + +# Shell File Naming + +Shell has no standard file layout, so follow one convention everywhere. + +| File | Case | Example | +| ---- | ---- | ------- | +| Script a person or tool runs | kebab-case, verb first | `find-review-requests.sh`, `safe-push.sh` | +| Its self-test | `test_` + the script name in snake_case | `test_find_review_requests.sh` | + +- **Kebab-case for scripts.** A script is a command, and commands read as kebab-case + (`git-subtree`, `apt-get`). Do not add snake_case scripts beside kebab-case ones. +- **Descriptive names, not `run.sh` or `main.sh`.** `$0`, error messages, `ps`, and + editor tabs show only the file name, so it must say what the script does. +- **`test_` snake_case for tests.** Tests are never typed as commands, and the prefix + matches pytest's `test_*.py`, so one search finds every test in the repo. + +## Co-locate the test with its script + +When a script has a test, give the pair its own directory, named after the script: + +```text +scripts/ +├── npx-skills-add/ +│ ├── npx-skills-add.sh +│ └── test_npx_skills_add.sh +└── apm-lock-check-ignored/ + ├── apm-lock-check-ignored.sh + └── test_apm_lock_check_ignored.sh +``` + +Several scripts that work together and share one directory, such as a skill's +`scripts/`, keep each test next to its script in that directory instead. + +The test finds its script relative to itself, never through the working directory: + +```bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"${SCRIPT_DIR}/find-review-requests.sh" --repo acme/widgets +``` + +## Renaming + +Rename with `git mv` and rename the test to match. Then search for the old name +(`rg -n 'old-name\.sh'`) and update every caller, doc, usage comment, and Makefile +or hook entry. When rewriting references in bulk, match whole file names, for example +`(?=22" + } + }, + "node_modules/@azu/format-text": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", + "integrity": "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==", + "license": "BSD-3-Clause" + }, + "node_modules/@azu/style-format": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@azu/style-format/-/style-format-1.0.1.tgz", + "integrity": "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==", + "license": "WTFPL", + "dependencies": { + "@azu/format-text": "^1.0.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@secretlint/config-creator": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-9.3.4.tgz", + "integrity": "sha512-GRMYfHJ+rewwB26CC3USVObqSQ/mDLXzXcUMJw/wJisPr3HDZmdsYlcsNnaAcGN+EZmvqSDkgSibQm1hyZpzbg==", + "license": "MIT", + "dependencies": { + "@secretlint/types": "^9.3.4" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/@secretlint/config-loader": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-9.3.4.tgz", + "integrity": "sha512-sy+yWDWh4cbAbpQYLiO39DjwNGEK1EUhTqNamLLBo163BdJP10FIWhqpe8mtGQBSBXRtxr8Hg/gc3Xe4meIoww==", + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^9.3.4", + "@secretlint/resolver": "^9.3.4", + "@secretlint/types": "^9.3.4", + "ajv": "^8.17.1", + "debug": "^4.4.1", + "rc-config-loader": "^4.1.3" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/@secretlint/core": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-9.3.4.tgz", + "integrity": "sha512-ErIVHI6CJd191qdNKuMkH3bZQo9mWJsrSg++bQx64o0WFuG5nPvkYrDK0p/lebf+iQuOnzvl5HrZU6GU9a6o+Q==", + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^9.3.4", + "@secretlint/types": "^9.3.4", + "debug": "^4.4.1", + "structured-source": "^4.0.0" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/@secretlint/formatter": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-9.3.4.tgz", + "integrity": "sha512-ARpoBOKz6WP3ocLITCFkR1/Lj636ugpBknylhlpc45r5aLdvmyvWAJqodlw5zmUCfgD6JXeAMf3Hi60aAiuqWQ==", + "license": "MIT", + "dependencies": { + "@secretlint/resolver": "^9.3.4", + "@secretlint/types": "^9.3.4", + "@textlint/linter-formatter": "^14.7.2", + "@textlint/module-interop": "^14.7.2", + "@textlint/types": "^14.7.2", + "chalk": "^4.1.2", + "debug": "^4.4.1", + "pluralize": "^8.0.0", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "terminal-link": "^2.1.1" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/@secretlint/formatter/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@secretlint/node": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-9.3.4.tgz", + "integrity": "sha512-S0u8i+CnPmyAKtuccgot9L5cmw6DqJc0F+b3hhVIALd8kkeLt3RIXOOej15tU7N0V1ISph90Gz92V72ovsprgQ==", + "license": "MIT", + "dependencies": { + "@secretlint/config-loader": "^9.3.4", + "@secretlint/core": "^9.3.4", + "@secretlint/formatter": "^9.3.4", + "@secretlint/profiler": "^9.3.4", + "@secretlint/source-creator": "^9.3.4", + "@secretlint/types": "^9.3.4", + "debug": "^4.4.1", + "p-map": "^4.0.0" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/@secretlint/profiler": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-9.3.4.tgz", + "integrity": "sha512-99WmaHd4dClNIm5BFsG++E6frNIZ3qVwg6s804Ql/M19pDmtZOoVCl4/UuzWpwNniBqLIgn9rHQZ/iGlIW3wyw==", + "license": "MIT" + }, + "node_modules/@secretlint/resolver": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-9.3.4.tgz", + "integrity": "sha512-L1lIrcjzqcspPzZttmOvMmOFDpJTYFyRBONg94TZBWrpv4x0w5G2SYR+K7EE1SbYQAiPxw1amoXT1YRP8cZF2A==", + "license": "MIT" + }, + "node_modules/@secretlint/secretlint-rule-preset-recommend": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-9.3.4.tgz", + "integrity": "sha512-RvzrLNN2A0B2bYQgRSRjh2dkdaIDuhXjj4SO5bElK1iBtJNiD6VBTxSSY1P3hXYaBeva7MEF+q1PZ3cCL8XYOA==", + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/@secretlint/source-creator": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-9.3.4.tgz", + "integrity": "sha512-I9ZA1gm9HJNaAhZiQdInY9VM04VTAGDV4bappVbEJzMUDnK/LTbYqfQ88RPqgCGCqa6ee8c0/j5Bn7ypweouIw==", + "license": "MIT", + "dependencies": { + "@secretlint/types": "^9.3.4", + "istextorbinary": "^9.5.0" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/@secretlint/types": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-9.3.4.tgz", + "integrity": "sha512-z9rdKHNeL4xa48+367RQJVw1d7/Js9HIQ+gTs/angzteM9osfgs59ad3iwVRhCGYbeUoUUDe2yxJG2ylYLaH3Q==", + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@textlint/ast-node-types": { + "version": "14.8.4", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-14.8.4.tgz", + "integrity": "sha512-+fI7miec/r9VeniFV9ppL4jRCmHNsTxieulTUf/4tvGII3db5hGriKHC4p/diq1SkQ9Sgs7kg6UyydxZtpTz1Q==", + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter": { + "version": "14.8.4", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-14.8.4.tgz", + "integrity": "sha512-sZ0UfYRDBNHnfMVBqLqqYnqTB7Ec169ljlmo+SEHR1T+dHUPYy1/DZK4p7QREXlBSFL4cnkswETCbc9xRodm4Q==", + "license": "MIT", + "dependencies": { + "@azu/format-text": "^1.0.2", + "@azu/style-format": "^1.0.1", + "@textlint/module-interop": "14.8.4", + "@textlint/resolver": "14.8.4", + "@textlint/types": "14.8.4", + "chalk": "^4.1.2", + "debug": "^4.4.1", + "js-yaml": "^3.14.1", + "lodash": "^4.17.21", + "pluralize": "^2.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "text-table": "^0.2.0" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/pluralize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", + "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", + "license": "MIT" + }, + "node_modules/@textlint/module-interop": { + "version": "14.8.4", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-14.8.4.tgz", + "integrity": "sha512-1LdPYLAVpa27NOt6EqvuFO99s4XLB0c19Hw9xKSG6xQ1K82nUEyuWhzTQKb3KJ5Qx7qj14JlXZLfnEuL6A16Bw==", + "license": "MIT" + }, + "node_modules/@textlint/resolver": { + "version": "14.8.4", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-14.8.4.tgz", + "integrity": "sha512-nMDOgDAVwNU9ommh+Db0U+MCMNDPbQ/1HBNjbnHwxZkCpcT6hsAJwBe38CW/DtWVUv8yeR4R40IYNPT84srNwA==", + "license": "MIT" + }, + "node_modules/@textlint/types": { + "version": "14.8.4", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-14.8.4.tgz", + "integrity": "sha512-9nyY8vVXlr8hHKxa6+37omJhXWCwovMQcgMteuldYd4dOxGm14AK2nXdkgtKEUQnzLGaXy46xwLCfhQy7V7/YA==", + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "14.8.4" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "license": "MIT" + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/binaryextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", + "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/boundary": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", + "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", + "license": "BSD-2-Clause" + }, + "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==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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/editions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", + "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", + "license": "Artistic-2.0", + "dependencies": { + "version-range": "^4.15.0" + }, + "engines": { + "ecmascript": ">= es5", + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "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==", + "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/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/ignore": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz", + "integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istextorbinary": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", + "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", + "license": "Artistic-2.0", + "dependencies": { + "binaryextensions": "^6.11.0", + "editions": "^6.21.0", + "textextensions": "^6.11.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz", + "integrity": "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==", + "license": "MIT", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.4.tgz", + "integrity": "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.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/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-7.1.1.tgz", + "integrity": "sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.21.4", + "error-ex": "^1.3.2", + "json-parse-even-better-errors": "^3.0.0", + "lines-and-columns": "^2.0.3", + "type-fest": "^3.8.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz", + "integrity": "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "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/rc-config-loader": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", + "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "json5": "^2.2.3", + "require-from-string": "^2.0.2" + } + }, + "node_modules/rc-config-loader/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==", + "license": "Python-2.0" + }, + "node_modules/rc-config-loader/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "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.js" + } + }, + "node_modules/read-pkg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-8.1.0.tgz", + "integrity": "sha512-PORM8AgzXeskHO/WEv312k9U03B8K9JSiWF/8N9sUuFjBa+9SF2u6K7VClzXwDXab51jCd8Nd36CNM+zR97ScQ==", + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.1", + "normalize-package-data": "^6.0.0", + "parse-json": "^7.0.0", + "type-fest": "^4.2.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "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==", + "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/secretlint": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-9.3.4.tgz", + "integrity": "sha512-iNOzgMX/+W1SQNW/TW6eikGChyaPiazr2AEXjzjpoB0R6QJEulvlwhn0KLT1/xjPfdYrk3yiXZM40csUqET8uQ==", + "license": "MIT", + "dependencies": { + "@secretlint/config-creator": "^9.3.4", + "@secretlint/formatter": "^9.3.4", + "@secretlint/node": "^9.3.4", + "@secretlint/profiler": "^9.3.4", + "debug": "^4.4.1", + "globby": "^14.1.0", + "read-pkg": "^8.1.0" + }, + "bin": { + "secretlint": "bin/secretlint.js" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz", + "integrity": "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==", + "license": "CC0-1.0" + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/structured-source": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", + "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", + "license": "BSD-2-Clause", + "dependencies": { + "boundary": "^2.0.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "license": "MIT" + }, + "node_modules/textextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", + "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/version-range": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", + "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==", + "license": "Artistic-2.0", + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yoctocolors": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..1f1adb63 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "essentials", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "ess-scan": "tools/typescript/essentials-sync/dist/ess-scan.js" + }, + "files": [ + "tools/typescript/essentials-sync/.secretlintrc.json", + "tools/typescript/essentials-sync/dist/" + ], + "scripts": { + "install": "test -f tools/typescript/essentials-sync/dist/ess-scan.js || tsc --project tools/typescript/essentials-sync --noCheck" + }, + "dependencies": { + "@secretlint/secretlint-rule-preset-recommend": "^9.0.0", + "chalk": "^5.3.0", + "commander": "^12.1.0", + "execa": "^9.5.1", + "globby": "^14.1.0", + "secretlint": "^9.0.0" + }, + "devDependencies": { + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=22" + } +} diff --git a/skills/ess/pr-address-comments-all/LICENSE b/packages/javascript/cisco/docs-docusaurus/LICENSE similarity index 100% rename from skills/ess/pr-address-comments-all/LICENSE rename to packages/javascript/cisco/docs-docusaurus/LICENSE diff --git a/skills/ess/pr-address-comments/LICENSE b/packages/python/azure-ai/LICENSE similarity index 100% rename from skills/ess/pr-address-comments/LICENSE rename to packages/python/azure-ai/LICENSE diff --git a/packages/python/azure-ai/pyproject.toml b/packages/python/azure-ai/pyproject.toml index e3895a98..ec6770aa 100644 --- a/packages/python/azure-ai/pyproject.toml +++ b/packages/python/azure-ai/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" description = "Azure OpenAI GPT-5 deployment with Pulumi" authors = [{ name = "Matt Norris", email = "matt@mattnorris.dev" }] readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [ "pulumi>=3.202.0,<4.0.0", @@ -15,7 +16,7 @@ dependencies = [ pulumi-utils = { workspace = true } [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/skills/ess/pr-review/LICENSE b/packages/python/ess-auth/LICENSE similarity index 100% rename from skills/ess/pr-review/LICENSE rename to packages/python/ess-auth/LICENSE diff --git a/packages/python/ess-auth/pyproject.toml b/packages/python/ess-auth/pyproject.toml index 69d3a929..dbcb3f08 100644 --- a/packages/python/ess-auth/pyproject.toml +++ b/packages/python/ess-auth/pyproject.toml @@ -2,6 +2,7 @@ name = "ess-auth" version = "0.1.0" description = "OAuth2 client-credentials helper and JWT verification utilities" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = ["httpx>=0.27", "PyJWT>=2.0.0"] @@ -9,8 +10,9 @@ dependencies = ["httpx>=0.27", "PyJWT>=2.0.0"] jwks = ["PyJWT[crypto]>=2.0.0"] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/ess_auth"] +exclude = ["**/test_*.py"] diff --git a/packages/python/ess-auth/src/ess_auth/__init__.py b/packages/python/ess-auth/src/ess_auth/__init__.py index 23dd9409..2b553fda 100644 --- a/packages/python/ess-auth/src/ess_auth/__init__.py +++ b/packages/python/ess-auth/src/ess_auth/__init__.py @@ -1,12 +1,36 @@ """OAuth2 client-credentials authentication and JWT verification utilities.""" +from .async_token_helper import AsyncTokenHelper, TokenClientOptions +from .client_identity import client_identity_headers +from .cooldown import FailureCooldown from .jwt import JWKSVerifier, decode_jwt, generate_jwt -from .token_helper import AuthenticationError, TokenHelper +from .rate_limit import ( + RateLimitStatus, + diagnostic_headers, + parse_rate_limit_headers, + rate_limit_hold_secs, +) +from .token_helper import ( + AuthenticationError, + TokenEndpointCoolingDownError, + TokenHelper, + TokenRequestFailedError, +) __all__ = [ + "AsyncTokenHelper", "AuthenticationError", + "FailureCooldown", "JWKSVerifier", + "RateLimitStatus", + "TokenClientOptions", + "TokenEndpointCoolingDownError", "TokenHelper", + "TokenRequestFailedError", + "client_identity_headers", "decode_jwt", + "diagnostic_headers", "generate_jwt", + "parse_rate_limit_headers", + "rate_limit_hold_secs", ] diff --git a/packages/python/ess-auth/src/ess_auth/async_token_helper.py b/packages/python/ess-auth/src/ess_auth/async_token_helper.py new file mode 100644 index 00000000..5fbdd724 --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/async_token_helper.py @@ -0,0 +1,394 @@ +"""Async OAuth2 client-credentials token helper. + +The async counterpart to :class:`ess_auth.TokenHelper`, for callers running +under an event loop. It exists separately rather than as an ``async`` method +on the sync helper because it needs per-event-loop lock state that has no +meaning in synchronous code. + +Why the per-loop locks +---------------------- +An ``asyncio.Lock`` binds to the running event loop the first time a coroutine +has to *wait* on a held lock (uncontended acquire/release never reaches that +path, which is why this only surfaces under concurrent refreshes). Frameworks +that create a fresh loop per request -- LangGraph among them -- leave a token +helper cached at module scope outliving the loop it first ran on, so once +contention has bound a shared lock to one loop, a second loop's contended +acquire raises:: + + RuntimeError: is bound to a different event loop + +This helper therefore caches one lock per running loop in a +``WeakKeyDictionary``. The values are weak references to the locks rather than +the locks themselves: once bound, a lock holds a *strong* reference to its +loop, and a strong value pointing back at its own weak key pins that key +forever, so the cache would grow with every request loop instead of shrinking. +A lock survives for as long as a caller is inside its ``async with``, which is +the only window where lock identity matters. Entries whose loop has been +closed are dropped on access so eviction does not wait for a collection cycle. + +An ``id()``-keyed dict would be unsafe because CPython reuses ``id`` values +after a loop is garbage collected. +""" + +from __future__ import annotations + +import asyncio +import logging +import threading +import time +import weakref +from collections.abc import Mapping +from dataclasses import dataclass + +import httpx + +from .client_identity import client_identity_headers +from .config import EXPIRATION_WINDOW +from .cooldown import FailureCooldown +from .rate_limit import diagnostic_headers +from .token_helper import ( + MIN_CACHE_LIFETIME_SECS, + STALE_TOKEN_SAFETY_SECS, + TokenEndpointCoolingDownError, + TokenRequestFailedError, + apply_rate_limit_headers, + error_detail, + parse_access_token, + parse_expires_in, +) + +logger = logging.getLogger(__name__) + +# Bounds lock wait plus network so a hung endpoint cannot stall a backlog of +# queued callers indefinitely. +_DEFAULT_TOTAL_TIMEOUT_SECS = 35.0 +_DEFAULT_REQUEST_TIMEOUT_SECS = 30.0 + + +@dataclass(frozen=True) +class TokenClientOptions: + """Optional behaviour for a token helper. + + Grouped into one object so the constructor stays readable as the number + of knobs grows. + + Attributes + ---------- + refresh_margin: + Seconds before expiry to trigger a proactive refresh. + scope: + OAuth2 scope, sent only when non-empty. Authorization servers differ + on whether an empty ``scope`` is acceptable. + credentials_in_body: + Send ``client_id``/``client_secret`` as form fields instead of HTTP + Basic auth. Both are valid client authentication methods and servers + differ on which they accept, so this is explicit rather than assumed. + cooldown_secs: + Override the failure cooldown window. See :mod:`ess_auth.cooldown`. + label: + Short name distinguishing this helper in log lines. + request_headers: + Extra headers for the token POST. ``None`` (the default) sends the + identification header configured in the environment; see + :mod:`ess_auth.client_identity`. + + Notes + ----- + The cooldown bounds attempts **per event loop**, not per process. The + locks are per-loop by necessity (an ``asyncio.Lock`` cannot be shared + across loops), so callers on different loops can pass the cooldown check + concurrently before the first failure records it. The worst case is one + in-flight attempt per live loop per window rather than a strict one, which + still collapses the per-request amplification this exists to stop. Making + it strict would need process-wide coordination that blocks event loops, so + it is deliberately not done here. + """ + + refresh_margin: int = EXPIRATION_WINDOW + scope: str = "" + credentials_in_body: bool = False + cooldown_secs: float | None = None + label: str = "OAuth" + request_headers: Mapping[str, str] | None = None + + +class AsyncTokenHelper: + """Manages OAuth2 client-credentials tokens for async callers. + + Uses ``threading.Lock`` for the cache read and token write (both + instantaneous) and a per-loop ``asyncio.Lock`` to serialise refreshes, so + waiting callers yield the event loop rather than blocking a thread. + + A token is refreshed ``refresh_margin`` seconds early. If that refresh + fails or is suppressed, the cached token keeps being served until it + actually expires, so one failed refresh never costs a still-valid token. + Rate-limit headers on any token response extend the cooldown; see + :mod:`ess_auth.rate_limit`. + + Parameters + ---------- + client_id: + OAuth2 client ID. + secret: + OAuth2 client secret. + token_url: + Token endpoint URL. + options: + Optional behaviour; see :class:`TokenClientOptions`. + """ + + def __init__( + self, + client_id: str, + secret: str, + *, + token_url: str, + options: TokenClientOptions | None = None, + ) -> None: + opts = options or TokenClientOptions() + self._client_id = client_id + self._secret = secret + self._token_url = token_url + self._refresh_margin = opts.refresh_margin + self._scope = opts.scope + self._credentials_in_body = opts.credentials_in_body + self._label = opts.label + self._request_headers = dict( + client_identity_headers() + if opts.request_headers is None + else opts.request_headers + ) + self._cooldown = FailureCooldown(opts.cooldown_secs) + + self._access_token: str | None = None + # ``_expires_at`` is when to refresh; ``_valid_until`` is when the + # token actually dies, which bounds how long a stale one is served. + self._expires_at: float = 0.0 + self._valid_until: float = 0.0 + self._lock = threading.Lock() + self._async_locks: weakref.WeakKeyDictionary[ + asyncio.AbstractEventLoop, weakref.ref[asyncio.Lock] + ] = weakref.WeakKeyDictionary() + + @property + def scope(self) -> str: + """The configured OAuth2 scope, empty when unscoped.""" + return self._scope + + @property + def token_url(self) -> str: + """The token endpoint URL, for caller-side diagnostics.""" + return self._token_url + + @property + def label(self) -> str: + """Short name distinguishing this helper in caller logs.""" + return self._label + + def _evict_closed_loops(self) -> None: + """Drop cache entries whose loop has been closed. + + Callers must hold ``self._lock``. + """ + closed = [loop for loop in self._async_locks if loop.is_closed()] + for loop in closed: + del self._async_locks[loop] + + def _get_async_lock(self) -> asyncio.Lock: + """Return the ``asyncio.Lock`` bound to the running loop. + + The caller must keep the returned lock referenced for as long as it + needs it; the cache holds it weakly. See the module docstring. + """ + loop = asyncio.get_running_loop() + with self._lock: + self._evict_closed_loops() + cached = self._async_locks.get(loop) + lock = cached() if cached is not None else None + if lock is None: + lock = asyncio.Lock() + self._async_locks[loop] = weakref.ref(lock) + return lock + + def _cached_token(self) -> str | None: + """Return the cached token when it is still inside its refresh window.""" + with self._lock: + if self._access_token and time.monotonic() < self._expires_at: + return self._access_token + return None + + def _stale_token(self) -> str | None: + """Return the cached token if it outlives a failed or suppressed refresh.""" + with self._lock: + token = self._access_token + valid_for = self._valid_until - time.monotonic() + if token is None or valid_for <= STALE_TOKEN_SAFETY_SECS: + return None + logger.warning( + "OAUTH_TOKEN_STALE_SERVED label=%s valid_for_s=%.0f token_url=%s", + self._label, + valid_for, + self._token_url, + ) + return token + + async def fetch_token(self) -> str: + """Return a valid access token, refreshing if necessary. + + Raises + ------ + TokenEndpointCoolingDownError + When a recent failure is still suppressing requests. + TokenRequestFailedError + When the token endpoint rejects or fails the request. + """ + cached = self._cached_token() + if cached is not None: + return cached + return await self.refresh() + + async def refresh(self) -> str: + """Perform the client-credentials grant and cache the result.""" + try: + return await asyncio.wait_for( + self._refresh_locked(), timeout=_DEFAULT_TOTAL_TIMEOUT_SECS + ) + except TimeoutError as exc: + logger.warning( + "%s token refresh exceeded %ss; abandoning attempt", + self._label, + _DEFAULT_TOTAL_TIMEOUT_SECS, + ) + # ``wait_for`` fires outside ``_do_refresh``, so without this the + # breaker stays closed and the next caller immediately starts a + # fresh attempt against an endpoint already known to be stuck -- + # the amplification path this class exists to close. Re-raised as + # the documented failure type so a hang reaches callers as an + # ``AuthenticationError`` like every other token failure. + self._log_cooldown() + stale = self._stale_token() + if stale is not None: + return stale + raise TokenRequestFailedError( + f"{self._label} token refresh exceeded " + f"{_DEFAULT_TOTAL_TIMEOUT_SECS:.0f}s" + ) from exc + + async def _refresh_locked(self) -> str: + async with self._get_async_lock(): + # Re-check: a concurrent caller may have refreshed while we waited. + cached = self._cached_token() + if cached is not None: + return cached + + cooling_for = self._cooldown.remaining() + if cooling_for > 0: + stale = self._stale_token() + if stale is not None: + return stale + raise TokenEndpointCoolingDownError( + f"{self._label} token endpoint is cooling down for " + f"{cooling_for:.0f}s after a recent failure; not retrying " + f"({self._token_url})" + ) + + try: + return await self._do_refresh() + except TokenRequestFailedError: + stale = self._stale_token() + if stale is None: + raise + return stale + + def _build_request_kwargs(self) -> dict: + """Return the form data and auth for the token POST.""" + data = {"grant_type": "client_credentials"} + if self._scope: + data["scope"] = self._scope + if self._credentials_in_body: + data["client_id"] = self._client_id + data["client_secret"] = self._secret + return {"data": data} + return {"data": data, "auth": (self._client_id, self._secret)} + + def _log_cooldown(self, response: httpx.Response | None = None) -> None: + """Open the breaker and record the window, which grows per failure. + + When the failed *response* is available, its rate-limit headers can + extend the window and its request-ID headers are logged. + """ + window = self._cooldown.start() + diagnostics: dict[str, str] = {} + status: int | str = "none" + if response is not None: + apply_rate_limit_headers(self._cooldown, response, label=self._label) + diagnostics = diagnostic_headers(response.headers) + status = response.status_code + # One line per failed attempt, including ones absorbed by serving a + # stale token, so this is the authoritative denial count. + logger.warning( + "OAUTH_TOKEN_COOLDOWN label=%s status=%s consecutive_failures=%d " + "window_s=%.0f token_url=%s diagnostics=%s", + self._label, + status, + self._cooldown.consecutive_failures, + window, + self._token_url, + diagnostics, + ) + + async def _do_refresh(self) -> str: + """Execute the token request, starting the cooldown on any failure.""" + try: + async with httpx.AsyncClient( + timeout=_DEFAULT_REQUEST_TIMEOUT_SECS + ) as client: + response = await client.post( + self._token_url, + headers=self._request_headers, + **self._build_request_kwargs(), + ) + response.raise_for_status() + payload = response.json() + access_token = parse_access_token(payload) + expires_in = parse_expires_in(payload) + except httpx.HTTPStatusError as exc: + self._log_cooldown(exc.response) + raise TokenRequestFailedError( + f"{self._label} token request failed: HTTP {exc.response.status_code}", + status_code=exc.response.status_code, + detail=error_detail(exc.response), + diagnostics=diagnostic_headers(exc.response.headers), + ) from exc + except httpx.HTTPError as exc: + self._log_cooldown() + raise TokenRequestFailedError( + f"{self._label} token request failed: {exc}" + ) from exc + except (ValueError, KeyError, TypeError) as exc: + # A malformed body would otherwise retry as hard as a denial. + # Carry status and detail like the HTTP paths do: a 2xx with an + # unusable body is exactly the case where a caller logging + # status=None and an empty detail has nothing to go on. + self._log_cooldown(response) + raise TokenRequestFailedError( + f"{self._label} token response was not usable: {exc}", + status_code=response.status_code, + detail=error_detail(response), + diagnostics=diagnostic_headers(response.headers), + ) from exc + + now = time.monotonic() + with self._lock: + self._access_token = access_token + self._expires_at = now + max( + expires_in - self._refresh_margin, MIN_CACHE_LIFETIME_SECS + ) + self._valid_until = now + expires_in + self._cooldown.clear() + # After clear(): a granted token can still report a nearly spent + # shared budget, and the hold must survive the reset. + apply_rate_limit_headers(self._cooldown, response, label=self._label) + + logger.info("%s token refreshed (expires_in=%ds)", self._label, expires_in) + return access_token diff --git a/packages/python/ess-auth/src/ess_auth/client_identity.py b/packages/python/ess-auth/src/ess_auth/client_identity.py new file mode 100644 index 00000000..4371550e --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/client_identity.py @@ -0,0 +1,63 @@ +"""Identify our token traffic to the edge in front of the token endpoint. + +Operators of an identity platform can match an agreed header key/value pair +at their edge or CDN layer and treat the traffic as a known service rather +than an anonymous source behind a shared NAT address. The pair is agreed out +of band, so it is configured through the environment (seeded from a secret +store) and never committed: + +* ``OAUTH_TOKEN_CLIENT_HEADER_NAME`` -- header name, e.g. ``X-Client-App`` +* ``OAUTH_TOKEN_CLIENT_HEADER_VALUE`` -- agreed value; treat as a secret + +Both must be set for the header to be sent. The value is never logged. +""" + +from __future__ import annotations + +import logging +import os +import re + +logger = logging.getLogger(__name__) + +HEADER_NAME_ENV = "OAUTH_TOKEN_CLIENT_HEADER_NAME" +HEADER_VALUE_ENV = "OAUTH_TOKEN_CLIENT_HEADER_VALUE" + +# RFC 9110 ``token`` characters. A name outside this set would make httpx +# raise on every request, which is worse than not identifying at all. +_HEADER_NAME_RE = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") +# Header names that would change how the token request authenticates or is +# routed rather than merely labelling it. +_RESERVED_NAMES = frozenset( + {"authorization", "content-type", "content-length", "host", "cookie"} +) + + +def client_identity_headers() -> dict[str, str]: + """Return the configured identification header, or an empty dict. + + Returns: + ``{name: value}`` when both environment variables are set and the + name is a valid, non-reserved header name; otherwise ``{}``. + """ + name = os.environ.get(HEADER_NAME_ENV, "").strip() + value = os.environ.get(HEADER_VALUE_ENV, "").strip() + if not name and not value: + return {} + if not name or not value: + logger.warning( + "%s and %s must both be set; token requests will not carry an " + "identification header", + HEADER_NAME_ENV, + HEADER_VALUE_ENV, + ) + return {} + if not _HEADER_NAME_RE.match(name) or name.lower() in _RESERVED_NAMES: + logger.warning( + "%s=%r is not an allowed header name; token requests will not " + "carry an identification header", + HEADER_NAME_ENV, + name, + ) + return {} + return {name: value} diff --git a/packages/python/ess-auth/src/ess_auth/cooldown.py b/packages/python/ess-auth/src/ess_auth/cooldown.py new file mode 100644 index 00000000..283adf78 --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/cooldown.py @@ -0,0 +1,158 @@ +"""Failure cooldown shared by the sync and async token helpers. + +A denied token request is not cached, so without a cooldown the next caller +retries immediately. Where the token is fetched per outbound request -- for +example from an ``httpx`` event hook -- that turns one denial into one +upstream request per call. + +That is a feedback loop rather than mere noise. Token endpoints commonly +rate-limit per source IP, and a whole Kubernetes cluster egresses through a +single NAT address, so retries escalate an intermittent denial into a +sustained one: the request rate climbs far above normal, denials come to +outnumber successes, and the endpoint can end up blocking the address +outright. + +This is a circuit breaker, not a retry policy +--------------------------------------------- +Nothing here sleeps, loops, or re-issues a request. A failure opens the +breaker; callers arriving while it is open fail fast without touching the +network, and the next caller after the window gets one attempt. That is the +opposite of ``tenacity``-style backoff, which keeps sending requests on a +schedule -- the behaviour this exists to stop. + +The *window* does grow exponentially, which is where backoff genuinely +applies. A fixed window is weak during a sustained outage: at 60s, a 90-minute +block still admits ~90 attempts per event loop. Doubling to a cap of one hour +admits about 7 over the same period, while still recovering promptly from a +brief blip. Jitter keeps a fleet of pods that started together from +synchronising their probes. + +There is no standard-library primitive for this, and pulling in a +circuit-breaker dependency is not worth it, so it is implemented here -- +deliberately small. +""" + +from __future__ import annotations + +import os +import secrets +import threading +import time + +_DEFAULT_COOLDOWN_SECS = 60.0 +_DEFAULT_MAX_COOLDOWN_SECS = 3600.0 +# Spread of the jitter applied to each window, as a fraction of its length. +_JITTER_FRACTION = 0.2 + + +def _read_env_float(name: str, default: float) -> float: + """Return a non-negative float from the environment, or *default*. + + Read from the environment so the windows can be retuned without a + redeploy; consumers that roll a new release to change an environment + variable pay a real cost for it. + """ + raw = os.environ.get(name) + if raw is None: + return default + try: + value = float(raw) + except ValueError: + return default + return value if value >= 0 else default + + +class FailureCooldown: + """Tracks whether token requests are currently suppressed. + + The window doubles on each consecutive failure, from ``cooldown_secs`` up + to ``max_cooldown_secs``, and resets on the first success. + + Thread-safe: the sync helper is called from worker threads and the async + helper from event loops, and one instance may be shared by both. + + Parameters + ---------- + cooldown_secs: + First window, in seconds. Defaults to + ``OAUTH_TOKEN_FAILURE_COOLDOWN_SECS`` or 60. + max_cooldown_secs: + Ceiling the window doubles towards. Defaults to + ``OAUTH_TOKEN_FAILURE_MAX_COOLDOWN_SECS`` or 3600. + """ + + def __init__( + self, + cooldown_secs: float | None = None, + max_cooldown_secs: float | None = None, + ) -> None: + self._base_secs = ( + _read_env_float("OAUTH_TOKEN_FAILURE_COOLDOWN_SECS", _DEFAULT_COOLDOWN_SECS) + if cooldown_secs is None + else cooldown_secs + ) + self._max_secs = ( + _read_env_float( + "OAUTH_TOKEN_FAILURE_MAX_COOLDOWN_SECS", _DEFAULT_MAX_COOLDOWN_SECS + ) + if max_cooldown_secs is None + else max_cooldown_secs + ) + self._consecutive_failures = 0 + self._retry_not_before: float = 0.0 + self._lock = threading.Lock() + + @property + def consecutive_failures(self) -> int: + """Failures since the last success, for logging and tests.""" + with self._lock: + return self._consecutive_failures + + def _window_for(self, failures: int) -> float: + """Return the jittered window length after *failures* consecutive failures.""" + # Doubling is computed on the exponent rather than by repeated + # multiplication so a long outage cannot drift the window. + raw = self._base_secs * (2 ** max(0, failures - 1)) + window = min(raw, self._max_secs) + # secrets rather than random: ruff flags the latter, and the quality + # difference is irrelevant for jitter. + jitter = (secrets.randbelow(1000) / 1000.0) * _JITTER_FRACTION + return window * (1.0 + jitter) + + def remaining(self) -> float: + """Return seconds left in the cooldown, or ``0.0`` when not cooling.""" + with self._lock: + return max(0.0, self._retry_not_before - time.monotonic()) + + def start(self) -> float: + """Open (or re-open) the breaker after a failed token request. + + Returns: + The window length just applied, in seconds. + """ + with self._lock: + self._consecutive_failures += 1 + window = self._window_for(self._consecutive_failures) + self._retry_not_before = time.monotonic() + window + return window + + def hold(self, seconds: float) -> None: + """Suppress requests for at least *seconds* without counting a failure. + + Used when the upstream says how long to wait -- a rate-limit reset or + ``Retry-After`` -- rather than when a request failed. An already + longer window is kept, and escalation is left untouched so the next + genuine failure still starts from where it would have. + """ + if seconds <= 0: + return + with self._lock: + self._retry_not_before = max( + self._retry_not_before, time.monotonic() + seconds + ) + + def clear(self) -> None: + """Close the breaker and reset escalation after a successful request.""" + with self._lock: + self._consecutive_failures = 0 + self._retry_not_before = 0.0 diff --git a/packages/python/ess-auth/src/ess_auth/rate_limit.py b/packages/python/ess-auth/src/ess_auth/rate_limit.py new file mode 100644 index 00000000..30b75fc0 --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/rate_limit.py @@ -0,0 +1,230 @@ +"""Rate-limit awareness from HTTP response headers, for any API caller. + +Many identity platforms report their remaining budget on every response, +success or failure:: + + X-Rate-Limit-Limit: 60 # ceiling for this client/IP/device window + X-Rate-Limit-Remaining: 35 # requests left in the current window + X-Rate-Limit-Reset: 1516307596 # UTC epoch second the window resets + +The quota is often counted per source identity, and a whole Kubernetes +cluster egresses through one NAT address, so ``Remaining`` reflects what the +*fleet* has spent, not one pod. Reading it lets every pod stop before the +shared budget runs out instead of discovering the limit by being refused -- +and sustained refusals are what earn a shared egress address a reputation +block at the edge. + +Nothing here performs I/O. Callers parse the headers they already received +and feed :func:`rate_limit_hold_secs` into their own +:class:`~ess_auth.cooldown.FailureCooldown` via ``hold()``, so the same policy +applies to the token helpers and to any other HTTP client that wants it. +""" + +from __future__ import annotations + +import math +import os +import time +from collections.abc import Mapping +from dataclasses import dataclass +from email.utils import parsedate_to_datetime +from http import HTTPStatus + +# Okta's spelling first; the hyphen-less variant is common elsewhere. +_LIMIT_HEADERS = ("x-rate-limit-limit", "x-ratelimit-limit") +_REMAINING_HEADERS = ("x-rate-limit-remaining", "x-ratelimit-remaining") +_RESET_HEADERS = ("x-rate-limit-reset", "x-ratelimit-reset") + +# Headers worth keeping when a request fails. Identity-platform support asks +# for a request or reference ID before it looks at a denial, and without one a +# denial is hard to trace. Credentials and cookies are deliberately absent. +DIAGNOSTIC_HEADERS: tuple[str, ...] = ( + "x-okta-request-id", + "x-request-id", + "x-correlation-id", + "akamai-grn", + "x-akamai-request-id", + "x-reference-error", + "server-timing", + *_LIMIT_HEADERS, + *_REMAINING_HEADERS, + *_RESET_HEADERS, + "retry-after", +) +_DIAGNOSTIC_VALUE_MAX_CHARS = 200 + +_DEFAULT_RESERVE_FRACTION = 0.1 +_DEFAULT_MAX_HOLD_SECS = 3600.0 + + +def _read_env_fraction(name: str, default: float) -> float: + """Return a fraction in ``[0, 1)`` from the environment, or *default*.""" + raw = os.environ.get(name) + if raw is None: + return default + try: + value = float(raw) + except ValueError: + return default + return value if 0.0 <= value < 1.0 else default + + +@dataclass(frozen=True) +class RateLimitStatus: + """Budget reported by one response. Any field may be absent.""" + + limit: int | None = None + remaining: int | None = None + reset_epoch: float | None = None + retry_after_secs: float | None = None + + def is_reported(self) -> bool: + """Whether the response carried any rate-limit information at all.""" + return any( + value is not None + for value in ( + self.limit, + self.remaining, + self.reset_epoch, + self.retry_after_secs, + ) + ) + + +def _first_header(headers: Mapping[str, str], names: tuple[str, ...]) -> str | None: + for name in names: + value = headers.get(name) + if value is not None and value.strip(): + return value.strip() + return None + + +def _parse_finite(raw: str) -> float | None: + """Parse *raw* as a finite float; ``inf``/``nan``/overflow are malformed.""" + try: + value = float(raw) + except ValueError: + return None + return value if math.isfinite(value) else None + + +def _parse_int(raw: str | None) -> int | None: + if raw is None: + return None + value = _parse_finite(raw) + if value is None or value < 0: + return None + return int(value) + + +def _parse_retry_after(raw: str | None, now_epoch: float) -> float | None: + """Parse ``Retry-After`` as delta-seconds or an HTTP date.""" + if raw is None: + return None + seconds = _parse_finite(raw) + if seconds is not None: + return max(0.0, seconds) + try: + when = parsedate_to_datetime(raw) + except (TypeError, ValueError, IndexError, OverflowError): + return None + return max(0.0, when.timestamp() - now_epoch) + + +def parse_rate_limit_headers( + headers: Mapping[str, str], *, now_epoch: float | None = None +) -> RateLimitStatus: + """Return the rate-limit budget described by *headers*. + + Args: + headers: Response headers. ``httpx.Headers`` is case-insensitive; a + plain dict must use lower-case names. + now_epoch: Current UTC epoch seconds, for tests. Defaults to now. + + Returns: + A status whose fields are ``None`` where a header was missing or + unparseable. Never raises on malformed values. + """ + now = time.time() if now_epoch is None else now_epoch + reset = _parse_int(_first_header(headers, _RESET_HEADERS)) + return RateLimitStatus( + limit=_parse_int(_first_header(headers, _LIMIT_HEADERS)), + remaining=_parse_int(_first_header(headers, _REMAINING_HEADERS)), + reset_epoch=float(reset) if reset is not None else None, + retry_after_secs=_parse_retry_after(headers.get("retry-after"), now), + ) + + +def _reserve_for(limit: int, reserve_fraction: float) -> int: + """Requests to keep in hand: at least one, else a fraction of the limit.""" + return max(1, math.ceil(limit * reserve_fraction)) + + +def rate_limit_hold_secs( + status: RateLimitStatus, + *, + status_code: int, + now_epoch: float | None = None, + reserve_fraction: float | None = None, + max_hold_secs: float = _DEFAULT_MAX_HOLD_SECS, +) -> float: + """Return how long to stop calling an API after this response. + + Two cases suppress further calls: + + * **Refused for rate.** On HTTP 429, wait for the longer of the time + until the window resets and ``Retry-After``. + * **Nearly exhausted.** On any status, when ``Remaining`` has fallen to + the reserve, wait until the window resets. The reserve is + ``API_RATE_LIMIT_RESERVE_FRACTION`` of the limit (default 10%), and + never less than one request. + + Args: + status: Parsed headers from :func:`parse_rate_limit_headers`. + status_code: HTTP status of the response. + now_epoch: Current UTC epoch seconds, for tests. Defaults to now. + reserve_fraction: Override the reserve fraction. + max_hold_secs: Ceiling on the returned hold, so a bogus reset time + cannot park a caller indefinitely. + + Returns: + Seconds to suppress calls, ``0.0`` when nothing suggests waiting. + """ + now = time.time() if now_epoch is None else now_epoch + until_reset = ( + max(0.0, status.reset_epoch - now) if status.reset_epoch is not None else None + ) + fraction = ( + _read_env_fraction("API_RATE_LIMIT_RESERVE_FRACTION", _DEFAULT_RESERVE_FRACTION) + if reserve_fraction is None + else reserve_fraction + ) + + hold = 0.0 + if status_code == HTTPStatus.TOO_MANY_REQUESTS: + # The longer of the two: a reset that is stale or skewed into the past + # must not cancel an explicit Retry-After. + hold = max(until_reset or 0.0, status.retry_after_secs or 0.0) + elif ( + status.remaining is not None + and status.limit + and status.remaining <= _reserve_for(status.limit, fraction) + and until_reset is not None + ): + hold = until_reset + return min(hold, max_hold_secs) + + +def diagnostic_headers(headers: Mapping[str, str]) -> dict[str, str]: + """Return the request-ID and rate-limit headers worth logging. + + Only names in :data:`DIAGNOSTIC_HEADERS` are copied, so credentials, + cookies, and tokens can never leak through this path. Values are + truncated to keep log lines bounded. + """ + found: dict[str, str] = {} + for name in DIAGNOSTIC_HEADERS: + value = headers.get(name) + if value: + found[name] = value[:_DIAGNOSTIC_VALUE_MAX_CHARS] + return found diff --git a/packages/python/ess-auth/src/ess_auth/test_async_token_helper.py b/packages/python/ess-auth/src/ess_auth/test_async_token_helper.py new file mode 100644 index 00000000..8379cdd8 --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/test_async_token_helper.py @@ -0,0 +1,446 @@ +"""Tests for the async token helper. + +Style note: synchronous pytest driving ``asyncio.run`` on a fresh loop per +test, mirroring how LangGraph creates a loop per request. Re-using one loop +would mask the cross-loop lock regression these tests protect. +""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import Callable +from typing import Any +from urllib.parse import parse_qs + +import httpx +import pytest + +from ess_auth import AsyncTokenHelper, TokenClientOptions +from ess_auth.token_helper import ( + TokenEndpointCoolingDownError, + TokenRequestFailedError, +) + + +def _patch_transport( + monkeypatch: pytest.MonkeyPatch, + *, + status: int = 200, + body: dict[str, Any] | None = None, + response_headers: dict[str, str] | None = None, +) -> list[httpx.Request]: + """Replace ``httpx.AsyncClient`` with a stub recording every request.""" + captured: list[httpx.Request] = [] + payload = body if body is not None else {"access_token": "tok", "expires_in": 3600} + + class _StubClient: + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + pass + + async def __aenter__(self) -> _StubClient: + return self + + async def __aexit__(self, *_exc: Any) -> None: + return None + + async def post(self, url: str, **kwargs: Any) -> httpx.Response: + request = httpx.Request( + "POST", url, data=kwargs.get("data"), headers=kwargs.get("headers") + ) + captured.append(request) + # Yield like a real network call would. Without this the holder + # never suspends, so a second caller finds the lock free, never + # waits on it, and the lock never binds to a loop -- which is + # the only state the per-loop tests below are about. + await asyncio.sleep(0) + return httpx.Response( + status, + request=request, + content=json.dumps(payload).encode(), + headers={ + "content-type": "application/json", + **(response_headers or {}), + }, + ) + + monkeypatch.setattr("ess_auth.async_token_helper.httpx.AsyncClient", _StubClient) + return captured + + +def _run_on_fresh_loop(factory: Callable[[], Any]) -> Any: + """Run ``factory()`` on a brand-new event loop and close it.""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(factory()) + finally: + loop.close() + + +def _helper(**kwargs: Any) -> AsyncTokenHelper: + return AsyncTokenHelper( + "cid", + "sec", + token_url="https://idp.example.com/token", + options=TokenClientOptions(label="test", **kwargs), + ) + + +class TestCaching: + def test_concurrent_callers_share_one_network_refresh( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + captured = _patch_transport(monkeypatch) + helper = _helper() + + async def _exercise() -> tuple[str, str]: + return await asyncio.gather(helper.fetch_token(), helper.fetch_token()) + + first, second = _run_on_fresh_loop(_exercise) + assert first == second == "tok" + assert len(captured) == 1 + + def test_refresh_succeeds_across_two_event_loops( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A module-scoped helper outlives the loop that first used it. + + Guards the ``Lock is bound to a different event loop`` failure that + motivated the per-loop lock cache. The lock only binds to a loop when + a coroutine has to *wait* on it, so both loops must actually contend: + each pair below is concurrent, and the cache is expired in between so + the second pair refreshes rather than returning the first loop's + token. + """ + _patch_transport(monkeypatch) + helper = _helper() + + async def _contend() -> Any: + # gather() on a cold cache forces one caller to wait on the lock. + return await asyncio.gather(helper.refresh(), helper.refresh()) + + assert _run_on_fresh_loop(_contend) == ["tok", "tok"] + + # Expire the cache so the second loop cannot short-circuit via + # _cached_token(); without this the lock is never contended twice and + # the cross-loop regression would go unnoticed. + helper._expires_at = 0.0 # noqa: SLF001 -- simulating token expiry + + assert _run_on_fresh_loop(_contend) == ["tok", "tok"] + + def test_short_lived_token_is_still_cached( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A lifetime at or below the refresh margin must not defeat the cache. + + Subtracting the margin unclamped puts the expiry in the past, so + every caller refreshes and one token request per call returns -- + the amplification the cooldown exists to stop, through the cache. + """ + captured = _patch_transport( + monkeypatch, body={"access_token": "tok", "expires_in": 30} + ) + helper = _helper(refresh_margin=120) + + async def _exercise() -> None: + await helper.fetch_token() + await helper.fetch_token() + + _run_on_fresh_loop(_exercise) + assert len(captured) == 1 + + @pytest.mark.parametrize("expires_in", [0, -1]) + def test_already_expired_token_is_rejected( + self, monkeypatch: pytest.MonkeyPatch, expires_in: int + ) -> None: + """A dead token must not be cached by the minimum-lifetime floor.""" + _patch_transport( + monkeypatch, body={"access_token": "tok", "expires_in": expires_in} + ) + helper = _helper() + + async def _exercise() -> None: + with pytest.raises(TokenRequestFailedError): + await helper.fetch_token() + + _run_on_fresh_loop(_exercise) + # Treated like any other unusable response: the breaker is open. + assert helper._cooldown.remaining() > 0 # noqa: SLF001 -- cooldown under test + + @pytest.mark.parametrize("access_token", ["", None, 12345]) + def test_unusable_access_token_is_rejected( + self, monkeypatch: pytest.MonkeyPatch, access_token: object + ) -> None: + """An empty or non-string token must not be cached.""" + _patch_transport( + monkeypatch, body={"access_token": access_token, "expires_in": 3600} + ) + helper = _helper() + + async def _exercise() -> None: + with pytest.raises(TokenRequestFailedError): + await helper.fetch_token() + + _run_on_fresh_loop(_exercise) + assert helper._cooldown.remaining() > 0 # noqa: SLF001 -- cooldown under test + + +class TestLockCache: + def test_closed_loops_are_evicted(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A loop per request must not make the cache grow without bound. + + A contended ``asyncio.Lock`` strongly references its loop, so a + cache holding locks by value would pin its own weak keys forever. + """ + _patch_transport(monkeypatch) + helper = _helper() + + async def _contend() -> Any: + return await asyncio.gather(helper.refresh(), helper.refresh()) + + for _ in range(3): + _run_on_fresh_loop(_contend) + helper._expires_at = 0.0 # noqa: SLF001 -- simulating token expiry + + assert len(helper._async_locks) <= 1 # noqa: SLF001 -- cache under test + + +class TestCooldown: + def test_timeout_opens_the_cooldown(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A hung endpoint is a failed attempt, not a free pass. + + ``asyncio.wait_for`` fires outside ``_do_refresh``, so the failure + has to be recorded in ``refresh`` itself or the next caller starts + a fresh attempt against an endpoint already known to be stuck. + """ + + class _HangingClient: + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + pass + + async def __aenter__(self) -> _HangingClient: + return self + + async def __aexit__(self, *_exc: Any) -> None: + return None + + async def post(self, _url: str, **_kwargs: Any) -> httpx.Response: + await asyncio.sleep(3600) + raise AssertionError("unreachable") + + monkeypatch.setattr( + "ess_auth.async_token_helper.httpx.AsyncClient", _HangingClient + ) + monkeypatch.setattr( + "ess_auth.async_token_helper._DEFAULT_TOTAL_TIMEOUT_SECS", 0.01 + ) + helper = _helper() + + async def _exercise() -> None: + with pytest.raises(TokenRequestFailedError): + await helper.fetch_token() + with pytest.raises(TokenEndpointCoolingDownError): + await helper.fetch_token() + + _run_on_fresh_loop(_exercise) + + def test_failure_suppresses_the_next_request( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + captured = _patch_transport( + monkeypatch, status=403, body={"error": "access_denied"} + ) + helper = _helper() + + async def _exercise() -> None: + with pytest.raises(TokenRequestFailedError): + await helper.fetch_token() + with pytest.raises(TokenEndpointCoolingDownError): + await helper.fetch_token() + + _run_on_fresh_loop(_exercise) + assert len(captured) == 1 + + def test_cooldown_expiry_allows_another_attempt( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + captured = _patch_transport( + monkeypatch, status=403, body={"error": "access_denied"} + ) + helper = _helper(cooldown_secs=60.0) + clock = {"now": 1000.0} + monkeypatch.setattr("ess_auth.cooldown.time.monotonic", lambda: clock["now"]) + + async def _exercise() -> None: + with pytest.raises(TokenRequestFailedError): + await helper.fetch_token() + clock["now"] = 1030.0 + with pytest.raises(TokenEndpointCoolingDownError): + await helper.fetch_token() + clock["now"] = 1100.0 # clear of the jittered window (<=72s) + with pytest.raises(TokenRequestFailedError): + await helper.fetch_token() + + _run_on_fresh_loop(_exercise) + denied_then_retried = 2 + assert len(captured) == denied_then_retried + + +class TestFullTokenLifetime: + """A failed proactive refresh must not throw away a still-valid token. + + Tokens refresh ``refresh_margin`` seconds early. Before this, a denial in + that window failed the caller even though the cached token had time + left, so every refresh denial became a user-visible tool failure. + """ + + def test_stale_token_is_served_when_refresh_is_denied( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + clock = {"now": 1000.0} + monkeypatch.setattr("ess_auth.cooldown.time.monotonic", lambda: clock["now"]) + _patch_transport(monkeypatch) + helper = _helper(refresh_margin=60) + assert _run_on_fresh_loop(helper.fetch_token) == "tok" + + denied = _patch_transport(monkeypatch, status=403, body={"error": "x"}) + clock["now"] = 1000.0 + 3600 - 30 # inside the margin, before expiry + + assert _run_on_fresh_loop(helper.fetch_token) == "tok" + assert len(denied) == 1 + # Still suppressed: the next caller gets the stale token, no request. + assert _run_on_fresh_loop(helper.fetch_token) == "tok" + assert len(denied) == 1 + + def test_expired_token_is_not_served(self, monkeypatch: pytest.MonkeyPatch) -> None: + clock = {"now": 1000.0} + monkeypatch.setattr("ess_auth.cooldown.time.monotonic", lambda: clock["now"]) + _patch_transport(monkeypatch) + helper = _helper(refresh_margin=60) + _run_on_fresh_loop(helper.fetch_token) + + _patch_transport(monkeypatch, status=403, body={"error": "x"}) + clock["now"] = 1000.0 + 3600 + 1 + + async def _exercise() -> None: + with pytest.raises(TokenRequestFailedError): + await helper.fetch_token() + + _run_on_fresh_loop(_exercise) + + +class TestRateLimitHeaders: + def test_429_reset_extends_the_cooldown( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Honour the server's reset rather than our shorter first window.""" + now_epoch = 1_700_000_000.0 + monkeypatch.setattr("ess_auth.rate_limit.time.time", lambda: now_epoch) + _patch_transport( + monkeypatch, + status=429, + body={"error": "too_many_requests"}, + response_headers={ + "X-Rate-Limit-Limit": "60", + "X-Rate-Limit-Remaining": "0", + "X-Rate-Limit-Reset": str(int(now_epoch + 600)), + }, + ) + helper = _helper(cooldown_secs=60.0) + + async def _exercise() -> None: + with pytest.raises(TokenRequestFailedError): + await helper.fetch_token() + + _run_on_fresh_loop(_exercise) + first_window_ceiling = 72.0 + assert helper._cooldown.remaining() > first_window_ceiling # noqa: SLF001 + + def test_nearly_spent_budget_suppresses_the_next_refresh( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + now_epoch = 1_700_000_000.0 + monkeypatch.setattr("ess_auth.rate_limit.time.time", lambda: now_epoch) + _patch_transport( + monkeypatch, + response_headers={ + "X-Rate-Limit-Limit": "60", + "X-Rate-Limit-Remaining": "2", + "X-Rate-Limit-Reset": str(int(now_epoch + 45)), + }, + ) + helper = _helper() + + assert _run_on_fresh_loop(helper.fetch_token) == "tok" + assert helper._cooldown.remaining() > 0 # noqa: SLF001 + assert helper._cooldown.consecutive_failures == 0 # noqa: SLF001 + + def test_failure_carries_request_id_for_upstream_support( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + _patch_transport( + monkeypatch, + status=403, + body={"error": "access_denied"}, + response_headers={"X-Okta-Request-Id": "req-123"}, + ) + helper = _helper() + + async def _exercise() -> TokenRequestFailedError: + with pytest.raises(TokenRequestFailedError) as err: + await helper.fetch_token() + return err.value + + error = _run_on_fresh_loop(_exercise) + assert error.diagnostics["x-okta-request-id"] == "req-123" + + +class TestClientIdentification: + def test_identification_header_comes_from_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("OAUTH_TOKEN_CLIENT_HEADER_NAME", "X-Client-App") + monkeypatch.setenv("OAUTH_TOKEN_CLIENT_HEADER_VALUE", "agreed") + captured = _patch_transport(monkeypatch) + + _run_on_fresh_loop(_helper().fetch_token) + + assert captured[0].headers["X-Client-App"] == "agreed" + + def test_explicit_headers_override_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("OAUTH_TOKEN_CLIENT_HEADER_NAME", "X-Client-App") + monkeypatch.setenv("OAUTH_TOKEN_CLIENT_HEADER_VALUE", "agreed") + captured = _patch_transport(monkeypatch) + + _run_on_fresh_loop(_helper(request_headers={}).fetch_token) + + assert "X-Client-App" not in captured[0].headers + + +class TestClientAuthentication: + def test_credentials_in_body_when_requested( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Some authorization servers want the credentials in the form body.""" + captured = _patch_transport(monkeypatch) + helper = _helper(credentials_in_body=True) + _run_on_fresh_loop(helper.fetch_token) + + form = parse_qs(captured[0].content.decode()) + assert form["client_id"] == ["cid"] + assert form["client_secret"] == ["sec"] + + def test_basic_auth_by_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + captured = _patch_transport(monkeypatch) + helper = _helper() + _run_on_fresh_loop(helper.fetch_token) + + assert "client_secret" not in parse_qs(captured[0].content.decode()) + + def test_scope_is_omitted_when_empty(self, monkeypatch: pytest.MonkeyPatch) -> None: + captured = _patch_transport(monkeypatch) + _run_on_fresh_loop(_helper().fetch_token) + + assert "scope" not in parse_qs(captured[0].content.decode()) diff --git a/packages/python/ess-auth/src/ess_auth/test_client_identity.py b/packages/python/ess-auth/src/ess_auth/test_client_identity.py new file mode 100644 index 00000000..c764327b --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/test_client_identity.py @@ -0,0 +1,61 @@ +"""Tests for the edge identification header read from the environment.""" + +from __future__ import annotations + +import logging + +import pytest + +from ess_auth.client_identity import ( + HEADER_NAME_ENV, + HEADER_VALUE_ENV, + client_identity_headers, +) + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(HEADER_NAME_ENV, raising=False) + monkeypatch.delenv(HEADER_VALUE_ENV, raising=False) + + +def test_unset_sends_nothing() -> None: + assert client_identity_headers() == {} + + +def test_both_set_sends_the_pair(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(HEADER_NAME_ENV, "X-Client-App") + monkeypatch.setenv(HEADER_VALUE_ENV, "agreed-value") + + assert client_identity_headers() == {"X-Client-App": "agreed-value"} + + +def test_half_configured_sends_nothing_and_warns( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setenv(HEADER_NAME_ENV, "X-Client-App") + + with caplog.at_level(logging.WARNING): + assert client_identity_headers() == {} + assert HEADER_VALUE_ENV in caplog.text + + +@pytest.mark.parametrize("name", ["Authorization", "bad name", "X:Colon"]) +def test_reserved_or_invalid_names_are_refused( + monkeypatch: pytest.MonkeyPatch, name: str +) -> None: + monkeypatch.setenv(HEADER_NAME_ENV, name) + monkeypatch.setenv(HEADER_VALUE_ENV, "agreed-value") + + assert client_identity_headers() == {} + + +def test_value_is_never_logged( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setenv(HEADER_NAME_ENV, "Authorization") + monkeypatch.setenv(HEADER_VALUE_ENV, "agreed-value") + + with caplog.at_level(logging.DEBUG): + client_identity_headers() + assert "agreed-value" not in caplog.text diff --git a/packages/python/ess-auth/src/ess_auth/test_cooldown.py b/packages/python/ess-auth/src/ess_auth/test_cooldown.py new file mode 100644 index 00000000..7f4975cb --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/test_cooldown.py @@ -0,0 +1,107 @@ +"""Tests for the failure cooldown's escalation behaviour. + +A fixed window is weak during a sustained outage. An identity platform that +refuses a cluster's shared egress address for 90 minutes straight will, at a +flat 60s window, still admit roughly 90 attempts per event loop, each one +feeding the rate limiter doing the refusing. Doubling towards an hourly cap +cuts the same period to single figures. +""" + +from __future__ import annotations + +from ess_auth.cooldown import _JITTER_FRACTION, FailureCooldown + + +def _cooldown(**kwargs: float) -> FailureCooldown: + return FailureCooldown(cooldown_secs=60.0, max_cooldown_secs=3600.0, **kwargs) + + +class TestEscalation: + def test_window_doubles_on_consecutive_failures(self) -> None: + cooldown = _cooldown() + windows = [cooldown.start() for _ in range(5)] + + # Jitter only ever extends a window, so each sits in [raw, raw * 1.2]. + for attempt, window in enumerate(windows, start=1): + raw = 60.0 * (2 ** (attempt - 1)) + assert raw <= window <= raw * (1 + _JITTER_FRACTION), ( + f"attempt {attempt}: {window} outside [{raw}, {raw * 1.2}]" + ) + + def test_window_is_capped(self) -> None: + """Without a ceiling, doubling would eventually park the breaker shut.""" + cooldown = _cooldown() + for _ in range(20): + window = cooldown.start() + + assert window <= 3600.0 * (1 + _JITTER_FRACTION) + + def test_success_resets_escalation(self) -> None: + """A blip must not leave the breaker on a long window afterwards.""" + failures_before_success = 4 + cooldown = _cooldown() + for _ in range(failures_before_success): + cooldown.start() + assert cooldown.consecutive_failures == failures_before_success + + cooldown.clear() + assert cooldown.consecutive_failures == 0 + assert cooldown.remaining() == 0.0 + + first_window_again = cooldown.start() + assert first_window_again <= 60.0 * (1 + _JITTER_FRACTION) + + def test_escalation_beats_a_flat_window_over_a_long_outage(self) -> None: + """The property that motivated escalation, stated as a test. + + Ninety minutes of continuous refusal should cost single-figure + attempts, not ~90. + """ + outage_secs = 90 * 60 + cooldown = _cooldown() + + elapsed = 0.0 + attempts = 0 + while elapsed < outage_secs: + elapsed += cooldown.start() + attempts += 1 + + flat_window_attempts = outage_secs / 60 + single_figures = 10 + worthwhile_reduction = 5 + assert attempts < single_figures + assert attempts < flat_window_attempts / worthwhile_reduction + + +class TestHold: + def test_hold_suppresses_without_counting_a_failure(self) -> None: + """An upstream-requested wait is not evidence the endpoint is broken.""" + cooldown = _cooldown() + + cooldown.hold(30.0) + + assert cooldown.remaining() > 0 + assert cooldown.consecutive_failures == 0 + + def test_hold_never_shortens_an_open_window(self) -> None: + cooldown = _cooldown() + window = cooldown.start() + + cooldown.hold(1.0) + + assert cooldown.remaining() > window - 1.0 + + def test_non_positive_hold_is_ignored(self) -> None: + cooldown = _cooldown() + + cooldown.hold(0.0) + + assert cooldown.remaining() == 0.0 + + +class TestJitter: + def test_jitter_desynchronises_a_fleet(self) -> None: + """Pods that start together must not probe in lockstep.""" + windows = {_cooldown().start() for _ in range(50)} + + assert len(windows) > 1 diff --git a/packages/python/ess-auth/src/ess_auth/test_rate_limit.py b/packages/python/ess-auth/src/ess_auth/test_rate_limit.py new file mode 100644 index 00000000..b0678374 --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/test_rate_limit.py @@ -0,0 +1,189 @@ +"""Tests for rate-limit header parsing and the hold policy. + +The header names and the example values follow the convention published by +Okta-backed identity platforms: ``X-Rate-Limit-Limit``, +``X-Rate-Limit-Remaining``, and ``X-Rate-Limit-Reset`` (UTC epoch seconds). +""" + +from __future__ import annotations + +import httpx +import pytest + +from ess_auth.rate_limit import ( + RateLimitStatus, + diagnostic_headers, + parse_rate_limit_headers, + rate_limit_hold_secs, +) + +_NOW = 1_516_307_536.0 +_RESET = 1_516_307_596 # sixty seconds after _NOW + + +def _headers(**values: str) -> httpx.Headers: + return httpx.Headers({k.replace("_", "-"): v for k, v in values.items()}) + + +class TestParse: + def test_reads_the_okta_headers(self) -> None: + status = parse_rate_limit_headers( + _headers( + X_Rate_Limit_Limit="60", + X_Rate_Limit_Remaining="35", + X_Rate_Limit_Reset=str(_RESET), + ), + now_epoch=_NOW, + ) + + assert status == RateLimitStatus( + limit=60, remaining=35, reset_epoch=float(_RESET) + ) + assert status.is_reported() + + def test_absent_headers_report_nothing(self) -> None: + status = parse_rate_limit_headers(httpx.Headers(), now_epoch=_NOW) + + assert not status.is_reported() + + def test_malformed_values_are_ignored_not_raised(self) -> None: + status = parse_rate_limit_headers( + _headers(X_Rate_Limit_Limit="lots", X_Rate_Limit_Remaining="-3"), + now_epoch=_NOW, + ) + + assert status.limit is None + assert status.remaining is None + + @pytest.mark.parametrize("raw", ["inf", "1e999", "nan", "-inf"]) + def test_non_finite_values_are_ignored_not_raised(self, raw: str) -> None: + """An upstream header must never abort token acquisition.""" + status = parse_rate_limit_headers( + _headers( + X_Rate_Limit_Limit=raw, + X_Rate_Limit_Remaining=raw, + X_Rate_Limit_Reset=raw, + Retry_After=raw, + ), + now_epoch=_NOW, + ) + + assert not status.is_reported() + + def test_retry_after_seconds(self) -> None: + status = parse_rate_limit_headers(_headers(Retry_After="17"), now_epoch=_NOW) + + retry_after = 17.0 + assert status.retry_after_secs == retry_after + + def test_retry_after_http_date(self) -> None: + # 2018-01-18T20:33:16Z is _RESET. + status = parse_rate_limit_headers( + _headers(Retry_After="Thu, 18 Jan 2018 20:33:16 GMT"), now_epoch=_NOW + ) + + assert status.retry_after_secs == pytest.approx(60.0) + + +class TestHold: + def test_healthy_budget_does_not_hold(self) -> None: + status = RateLimitStatus(limit=60, remaining=35, reset_epoch=_RESET) + + assert rate_limit_hold_secs(status, status_code=200, now_epoch=_NOW) == 0.0 + + def test_nearly_spent_budget_holds_until_reset(self) -> None: + """Stop before the shared egress budget runs out, not after.""" + status = RateLimitStatus(limit=60, remaining=6, reset_epoch=_RESET) + + hold = rate_limit_hold_secs(status, status_code=200, now_epoch=_NOW) + + assert hold == pytest.approx(60.0) + + def test_reserve_is_never_below_one_request(self) -> None: + """A tiny limit still keeps the last request in hand.""" + status = RateLimitStatus(limit=5, remaining=1, reset_epoch=_RESET) + + hold = rate_limit_hold_secs( + status, status_code=200, now_epoch=_NOW, reserve_fraction=0.0 + ) + + assert hold > 0 + + def test_429_holds_until_reset(self) -> None: + status = RateLimitStatus(limit=60, remaining=0, reset_epoch=_RESET) + + hold = rate_limit_hold_secs(status, status_code=429, now_epoch=_NOW) + + assert hold == pytest.approx(60.0) + + def test_429_stale_reset_does_not_cancel_retry_after(self) -> None: + """A reset skewed into the past must not drop an explicit wait.""" + status = RateLimitStatus( + limit=60, remaining=0, reset_epoch=_NOW - 30, retry_after_secs=45.0 + ) + + hold = rate_limit_hold_secs(status, status_code=429, now_epoch=_NOW) + + retry_after = 45.0 + assert hold == retry_after + + def test_429_waits_for_the_longer_of_reset_and_retry_after(self) -> None: + status = RateLimitStatus( + limit=60, remaining=0, reset_epoch=_RESET, retry_after_secs=10.0 + ) + + hold = rate_limit_hold_secs(status, status_code=429, now_epoch=_NOW) + + assert hold == pytest.approx(60.0) + + def test_429_without_reset_uses_retry_after(self) -> None: + status = RateLimitStatus(retry_after_secs=12.0) + + hold = rate_limit_hold_secs(status, status_code=429, now_epoch=_NOW) + + retry_after = 12.0 + assert hold == retry_after + + def test_hold_is_capped(self) -> None: + """A bogus reset far in the future must not park callers forever.""" + status = RateLimitStatus(limit=60, remaining=0, reset_epoch=_NOW + 10**9) + + hold = rate_limit_hold_secs( + status, status_code=429, now_epoch=_NOW, max_hold_secs=3600.0 + ) + + cap = 3600.0 + assert hold == cap + + def test_reset_in_the_past_does_not_hold(self) -> None: + status = RateLimitStatus(limit=60, remaining=0, reset_epoch=_NOW - 5) + + assert rate_limit_hold_secs(status, status_code=200, now_epoch=_NOW) == 0.0 + + def test_reserve_fraction_is_tunable_from_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("API_RATE_LIMIT_RESERVE_FRACTION", "0.5") + status = RateLimitStatus(limit=60, remaining=25, reset_epoch=_RESET) + + assert rate_limit_hold_secs(status, status_code=200, now_epoch=_NOW) > 0 + + +class TestDiagnosticHeaders: + def test_keeps_request_ids_and_budget(self) -> None: + headers = _headers( + X_Okta_Request_Id="abc123", + X_Rate_Limit_Remaining="0", + Akamai_GRN="0.1234.5678", + ) + + assert diagnostic_headers(headers) == { + "x-okta-request-id": "abc123", + "akamai-grn": "0.1234.5678", + "x-rate-limit-remaining": "0", + } + + def test_never_copies_credentials_or_cookies(self) -> None: + headers = _headers(Set_Cookie="sid=secret", Authorization="Bearer secret") + + assert diagnostic_headers(headers) == {} diff --git a/packages/python/ess-auth/src/ess_auth/test_token_helper.py b/packages/python/ess-auth/src/ess_auth/test_token_helper.py new file mode 100644 index 00000000..108312df --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/test_token_helper.py @@ -0,0 +1,345 @@ +"""Tests for the sync token helper's cooldown and scope behaviour. + +The cooldown exists because a denied token request is not cached: without it, +every subsequent caller retries immediately against an endpoint that +rate-limits per source IP, turning an intermittent denial into a sustained one. +""" + +from __future__ import annotations + +import json +import threading +import time +from typing import Any +from urllib.parse import parse_qs + +import httpx +import pytest + +from ess_auth import token_helper +from ess_auth.token_helper import ( + AuthenticationError, + TokenEndpointCoolingDownError, + TokenHelper, + TokenRequestFailedError, +) + + +def _client( + captured: list[httpx.Request], + *, + status: int = 200, + body: dict[str, Any] | None, + response_headers: dict[str, str] | None = None, +) -> httpx.Client: + """Return an httpx.Client whose transport records and canned-responds.""" + payload = body if body is not None else {"access_token": "tok", "expires_in": 3600} + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response( + status, + content=json.dumps(payload).encode(), + headers={"content-type": "application/json", **(response_headers or {})}, + ) + + return httpx.Client(transport=httpx.MockTransport(handler)) + + +def _helper(**kwargs: Any) -> TokenHelper: + return TokenHelper( + "cid", "sec", token_url="https://idp.example.com/token", **kwargs + ) + + +class TestCooldown: + def test_failure_suppresses_the_next_request(self) -> None: + captured: list[httpx.Request] = [] + helper = _helper() + http = _client(captured, status=403, body={"error": "access_denied"}) + + with pytest.raises(TokenRequestFailedError): + helper.fetch_token(http) + with pytest.raises(TokenEndpointCoolingDownError): + helper.fetch_token(http) + + # The second call never reached the endpoint. + assert len(captured) == 1 + + def test_cooldown_expiry_allows_another_attempt( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + captured: list[httpx.Request] = [] + helper = _helper() + http = _client(captured, status=403, body={"error": "access_denied"}) + + clock = {"now": 1000.0} + monkeypatch.setattr("ess_auth.cooldown.time.monotonic", lambda: clock["now"]) + monkeypatch.setattr(token_helper.time, "monotonic", lambda: clock["now"]) + + with pytest.raises(TokenRequestFailedError): + helper.fetch_token(http) + + clock["now"] = 1030.0 # still inside the window + with pytest.raises(TokenEndpointCoolingDownError): + helper.fetch_token(http) + + clock["now"] = 1100.0 # clear of the jittered window (<=72s) + with pytest.raises(TokenRequestFailedError): + helper.fetch_token(http) + + denied_then_retried = 2 + assert len(captured) == denied_then_retried + + def test_success_clears_the_cooldown(self) -> None: + helper = _helper() + denied: list[httpx.Request] = [] + with pytest.raises(TokenRequestFailedError): + helper.fetch_token(_client(denied, status=403, body={"e": "x"})) + + # A fresh helper state would hide a stuck cooldown, so reuse this one. + helper._cooldown.clear() # noqa: SLF001 -- simulating the window passing + granted: list[httpx.Request] = [] + assert helper.fetch_token(_client(granted, body=None)) == "tok" + assert helper._cooldown.remaining() == 0.0 # noqa: SLF001 + + def test_concurrent_threads_make_one_request(self) -> None: + """The cooldown caps attempts per client, so the check must be atomic. + + Two worker threads on a cold cache can otherwise both observe a + closed breaker before either failure opens it, and both reach the + endpoint the cooldown is meant to shield. + """ + captured: list[httpx.Request] = [] + helper = _helper() + started = threading.Barrier(2) + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + # Widen the window in which an unserialised second caller would + # still see a closed breaker, so the regression cannot pass by + # winning a microsecond-wide race. + time.sleep(0.05) + return httpx.Response( + 403, + content=json.dumps({"error": "access_denied"}).encode(), + headers={"content-type": "application/json"}, + ) + + http = httpx.Client(transport=httpx.MockTransport(handler)) + + errors: list[Exception] = [] + + def _attempt() -> None: + try: + started.wait(timeout=5) + helper.fetch_token(http) + except AuthenticationError: + # Either a live refusal (the winner) or a suppressed + # attempt (the loser); both are the expected outcome. + pass + except Exception as exc: # noqa: BLE001 -- surfaced in the main thread + errors.append(exc) + + threads = [threading.Thread(target=_attempt) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + # A worker that hung or died would leave the request-count + # assertion below trivially satisfied, so check it landed. + assert not thread.is_alive(), "worker thread did not finish" + assert errors == [], f"worker raised unexpectedly: {errors}" + + # The loser of the race is refused by the cooldown, not the endpoint. + assert len(captured) == 1 + + +class TestCaching: + def test_short_lived_token_is_still_cached(self) -> None: + """A lifetime at or below the refresh margin must not defeat the cache. + + Treating such a token as expired on arrival means one token request + per call, which is the amplification the cooldown exists to stop. + """ + captured: list[httpx.Request] = [] + helper = _helper(refresh_margin=120) + http = _client(captured, body={"access_token": "tok", "expires_in": 30}) + + assert helper.fetch_token(http) == "tok" + assert helper.fetch_token(http) == "tok" + assert len(captured) == 1 + + @pytest.mark.parametrize("expires_in", [0, -1]) + def test_already_expired_token_is_rejected(self, expires_in: int) -> None: + """A dead token must not be cached by the minimum-lifetime floor. + + The floor keeps a short-but-valid token usable; it must not make + an already-expired one look usable for a second. + """ + helper = _helper() + http = _client([], body={"access_token": "tok", "expires_in": expires_in}) + + with pytest.raises(TokenRequestFailedError): + helper.fetch_token(http) + # Treated like any other unusable response: the breaker is open. + assert helper._cooldown.remaining() > 0 # noqa: SLF001 -- cooldown under test + + +class TestErrorDetail: + """Coverage for the shared summarizer. + + ``error_detail`` lives alongside the sync helper and is used by the async + one too, so its behaviour is pinned down here once. + """ + + def test_prefers_the_oauth_error_fields(self) -> None: + response = httpx.Response( + 403, + json={"error": "invalid_scope", "error_description": "bad scope"}, + ) + + assert token_helper.error_detail(response) == "invalid_scope bad scope" + + def test_falls_back_to_raw_text_for_non_json(self) -> None: + """An edge proxy denial is HTML, not an OAuth error body.""" + response = httpx.Response(403, text=" Forbidden\n by edge ") + + # Whitespace is collapsed so the excerpt stays one log-friendly line. + assert token_helper.error_detail(response) == " Forbidden by edge " + + def test_is_truncated_to_the_cap(self) -> None: + response = httpx.Response(403, text="x" * 1000) + + detail = token_helper.error_detail(response) + + assert len(detail) == token_helper._ERROR_DETAIL_MAX_CHARS # noqa: SLF001 -- cap under test + + +class TestMalformedResponses: + @pytest.mark.parametrize("access_token", ["", None, 12345]) + def test_unusable_access_token_is_rejected(self, access_token: object) -> None: + """An empty or non-string token must not be cached. + + It would otherwise clear the cooldown and leave callers sending + ``Bearer`` followed by nothing useful. + """ + helper = _helper() + http = _client([], body={"access_token": access_token, "expires_in": 3600}) + + with pytest.raises(TokenRequestFailedError): + helper.fetch_token(http) + assert helper._cooldown.remaining() > 0 # noqa: SLF001 -- cooldown under test + + def test_malformed_2xx_honours_a_spent_rate_limit( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A spent budget on an unusable 2xx must hold until the reset. + + The generic failure window is ~60s; the reset here is 10 minutes out. + """ + now_epoch = 1_700_000_000.0 + monkeypatch.setattr("ess_auth.rate_limit.time.time", lambda: now_epoch) + helper = _helper() + http = _client( + [], + body={"access_token": "", "expires_in": 3600}, + response_headers={ + "X-Rate-Limit-Limit": "60", + "X-Rate-Limit-Remaining": "0", + "X-Rate-Limit-Reset": str(int(now_epoch + 600)), + }, + ) + + with pytest.raises(TokenRequestFailedError): + helper.fetch_token(http) + + first_window_ceiling = 72.0 + assert helper._cooldown.remaining() > first_window_ceiling # noqa: SLF001 + + +class TestFailureDetail: + def test_status_and_detail_are_exposed_for_caller_logging(self) -> None: + """Callers log their own context; they should not re-read the response.""" + helper = _helper() + http = _client([], status=403, body={"error": "access_denied"}) + + with pytest.raises(TokenRequestFailedError) as err: + helper.fetch_token(http) + + forbidden = 403 + assert err.value.status_code == forbidden + assert "access_denied" in err.value.detail + + def test_request_id_is_exposed_for_upstream_support(self) -> None: + helper = _helper() + http = _client( + [], + status=403, + body={"error": "access_denied"}, + response_headers={"X-Okta-Request-Id": "req-123"}, + ) + + with pytest.raises(TokenRequestFailedError) as err: + helper.fetch_token(http) + + assert err.value.diagnostics == {"x-okta-request-id": "req-123"} + + +class TestFullTokenLifetime: + def test_stale_token_is_served_when_refresh_is_denied( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + clock = {"now": 1000.0} + monkeypatch.setattr("ess_auth.cooldown.time.monotonic", lambda: clock["now"]) + helper = _helper(refresh_margin=60) + assert helper.fetch_token(_client([], body=None)) == "tok" + + denied: list[httpx.Request] = [] + clock["now"] = 1000.0 + 3600 - 30 # inside the margin, before expiry + http = _client(denied, status=403, body={"error": "x"}) + + assert helper.fetch_token(http) == "tok" + assert helper.fetch_token(http) == "tok" + assert len(denied) == 1 + + def test_expired_token_is_not_served(self, monkeypatch: pytest.MonkeyPatch) -> None: + clock = {"now": 1000.0} + monkeypatch.setattr("ess_auth.cooldown.time.monotonic", lambda: clock["now"]) + helper = _helper(refresh_margin=60) + helper.fetch_token(_client([], body=None)) + + clock["now"] = 1000.0 + 3600 + 1 + with pytest.raises(TokenRequestFailedError): + helper.fetch_token(_client([], status=403, body={"error": "x"})) + + +class TestClientIdentification: + def test_identification_header_comes_from_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("OAUTH_TOKEN_CLIENT_HEADER_NAME", "X-Client-App") + monkeypatch.setenv("OAUTH_TOKEN_CLIENT_HEADER_VALUE", "agreed") + captured: list[httpx.Request] = [] + + _helper().fetch_token(_client(captured, body=None)) + + assert captured[0].headers["X-Client-App"] == "agreed" + + +class TestScope: + def test_scope_is_sent_when_set(self) -> None: + captured: list[httpx.Request] = [] + helper = _helper(scope="example-scope") + helper.fetch_token(_client(captured, body=None)) + + form = parse_qs(captured[0].content.decode()) + assert form["scope"] == ["example-scope"] + + def test_scope_is_omitted_when_empty(self) -> None: + """Some authorization servers reject an empty scope parameter.""" + captured: list[httpx.Request] = [] + helper = _helper() + helper.fetch_token(_client(captured, body=None)) + + assert "scope" not in parse_qs(captured[0].content.decode()) diff --git a/packages/python/ess-auth/src/ess_auth/token_helper.py b/packages/python/ess-auth/src/ess_auth/token_helper.py index f32f04c7..346b334e 100644 --- a/packages/python/ess-auth/src/ess_auth/token_helper.py +++ b/packages/python/ess-auth/src/ess_auth/token_helper.py @@ -3,11 +3,19 @@ from __future__ import annotations import logging +import threading import time import httpx +from .client_identity import client_identity_headers from .config import EXPIRATION_WINDOW +from .cooldown import FailureCooldown +from .rate_limit import ( + diagnostic_headers, + parse_rate_limit_headers, + rate_limit_hold_secs, +) logger = logging.getLogger(__name__) @@ -16,6 +24,148 @@ class AuthenticationError(Exception): """Raised when a token request fails.""" +class TokenEndpointCoolingDownError(AuthenticationError): + """Raised when a token request is suppressed after a recent failure. + + Distinct from a live rejection so callers can tell "we did not ask" from + "we asked and were refused". Consumers that must not crash on this should + catch it explicitly; see ``ess_auth.cooldown`` for why the suppression + exists. + """ + + +class TokenRequestFailedError(AuthenticationError): + """Raised when the token endpoint rejects or fails a request. + + Carries the HTTP status and a short response excerpt so callers can log + their own context-rich diagnostics without re-reading the response. + + Attributes + ---------- + status_code: + HTTP status returned by the token endpoint, when there was one. + detail: + Truncated response body, useful for distinguishing an OAuth JSON + error from an HTML denial emitted by an edge proxy. + diagnostics: + Request-ID and rate-limit response headers (see + :data:`ess_auth.rate_limit.DIAGNOSTIC_HEADERS`). Identity-platform + support needs the request ID to trace a denial. + """ + + def __init__( + self, + message: str, + *, + status_code: int | None = None, + detail: str = "", + diagnostics: dict[str, str] | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.detail = detail + self.diagnostics = diagnostics or {} + + +# Cap on token-endpoint error text copied into exceptions and logs. +_ERROR_DETAIL_MAX_CHARS = 300 + +# Floor on how long a freshly issued token stays cached. Subtracting the +# refresh margin from a token whose lifetime is at or below that margin would +# otherwise place the expiry at or before now, so every caller would refresh +# on every call -- the per-request amplification the cooldown exists to stop, +# reintroduced through the cache. Shared with the async helper. +MIN_CACHE_LIFETIME_SECS = 1.0 + +# A cached token is refreshed ``refresh_margin`` seconds before it expires. +# When that refresh fails, the old token is still good until real expiry and +# is served instead of failing the caller. This margin keeps a token from +# being handed out so close to expiry that it dies in flight. +STALE_TOKEN_SAFETY_SECS = 5.0 + + +def apply_rate_limit_headers( + cooldown: FailureCooldown, response: httpx.Response, *, label: str +) -> None: + """Suppress further token requests when *response* says to wait. + + Shared by the sync and async helpers. See :mod:`ess_auth.rate_limit`. + """ + status = parse_rate_limit_headers(response.headers) + if not status.is_reported(): + return + hold = rate_limit_hold_secs(status, status_code=response.status_code) + if hold <= 0: + return + cooldown.hold(hold) + logger.warning( + "OAUTH_RATE_LIMIT_HOLD label=%s status=%d limit=%s remaining=%s hold_s=%.0f", + label, + response.status_code, + status.limit, + status.remaining, + hold, + ) + + +def parse_access_token(payload: dict) -> str: + """Return the access token from *payload*, rejecting unusable values. + + A missing key is already a ``KeyError``, but an empty string, ``null``, + or a number is not: it would be cached, clear the cooldown, and leave + callers sending ``Bearer`` with nothing useful after it. Call from + inside the body-parsing ``try`` so the ``ValueError`` lands on the + malformed-response path. + + Raises: + ValueError: If the token is not a non-empty string. + """ + access_token = payload["access_token"] + if not isinstance(access_token, str) or not access_token: + raise ValueError( + f"access_token was {type(access_token).__name__}, " + "expected a non-empty string" + ) + return access_token + + +def parse_expires_in(payload: dict) -> int: + """Return the token lifetime from *payload*, rejecting dead tokens. + + A non-positive ``expires_in`` describes a token that has already + expired. Caching it -- even for the one-second floor -- would send a + dead credential upstream, so it is treated as an unusable response. + Call from inside the body-parsing ``try`` so the ``ValueError`` lands + on the malformed-response path that opens the failure cooldown. + + Raises: + ValueError: If ``expires_in`` is not a positive integer. + """ + expires_in = int(payload.get("expires_in", 3600)) + if expires_in <= 0: + raise ValueError(f"expires_in was {expires_in}, expected a positive lifetime") + return expires_in + + +def error_detail(response: httpx.Response) -> str: + """Summarize a token-endpoint error response for logging. + + Prefers the OAuth2 ``error``/``error_description`` fields and falls back + to raw text, which is what an edge proxy returning HTML will produce. + """ + try: + body = response.json() + except ValueError: + body = None + + fields = "" + if isinstance(body, dict): + fields = f"{body.get('error', '')} {body.get('error_description', '')}" + + detail = " ".join(fields.split()) or " ".join(response.text.split()) + return detail[:_ERROR_DETAIL_MAX_CHARS] + + class TokenHelper: """Manages OAuth2 client-credentials tokens with caching and auto-refresh. @@ -32,6 +182,18 @@ class TokenHelper: Token endpoint URL. refresh_margin: Seconds before expiry to trigger a proactive refresh. + scope: + Optional OAuth2 scope. Sent only when non-empty, so authorization + servers that reject an empty ``scope`` parameter are unaffected. + Notes + ----- + The failure cooldown window is read from + ``OAUTH_TOKEN_FAILURE_COOLDOWN_SECS``; see :mod:`ess_auth.cooldown`. + Rate-limit response headers extend it; see :mod:`ess_auth.rate_limit`. + When a proactive refresh fails, the cached token is served until it + actually expires, so a token is used for its full lifetime. The token + POST carries the identification header configured in the environment; + see :mod:`ess_auth.client_identity`. """ def __init__( @@ -41,14 +203,20 @@ def __init__( *, token_url: str, refresh_margin: int = EXPIRATION_WINDOW, + scope: str = "", ) -> None: self._client_id = client_id self._secret = secret self._token_url = token_url self._refresh_margin = refresh_margin + self._scope = scope + self._request_headers = client_identity_headers() + self._cooldown = FailureCooldown() + self._lock = threading.Lock() self._access_token: str | None = None self._expires_at: float = 0.0 + self._usable_until: float = 0.0 @property def token(self) -> str | None: @@ -58,9 +226,7 @@ def token(self) -> str | None: @property def is_expired(self) -> bool: """Whether the cached token is missing or within the refresh window.""" - return self._access_token is None or time.monotonic() >= ( - self._expires_at - self._refresh_margin - ) + return self._access_token is None or time.monotonic() >= self._usable_until def fetch_token(self, http: httpx.Client | None = None) -> str: """Return a valid access token, refreshing if necessary. @@ -71,42 +237,113 @@ def fetch_token(self, http: httpx.Client | None = None) -> str: Optional ``httpx.Client`` to reuse. A short-lived client is created automatically when *None*. """ - if not self.is_expired: - assert self._access_token is not None # noqa: S101 # nosec B101 # narrowing assertion after None check - return self._access_token - - if http is not None: - self._refresh(http) - else: - with httpx.Client(timeout=30.0) as client: - self._refresh(client) + # The cooldown check and the refresh have to be atomic: two worker + # threads arriving on a cold or expired cache can otherwise both see + # a closed breaker before either failure opens it, and both reach the + # endpoint the cooldown exists to shield. The cached read stays + # outside the lock so it never queues behind an in-flight refresh. + if self.is_expired: + with self._lock: + if self.is_expired: + try: + self._refresh_guarded(http) + except AuthenticationError: + stale = self._stale_token() + if stale is None: + raise + return stale assert self._access_token is not None # noqa: S101 # nosec B101 # narrowing assertion after None check return self._access_token + def _stale_token(self) -> str | None: + """Return the cached token if it outlives a failed refresh. + + Callers must hold ``self._lock``. + """ + valid_for = self._expires_at - time.monotonic() + if self._access_token is None or valid_for <= STALE_TOKEN_SAFETY_SECS: + return None + logger.warning( + "OAUTH_TOKEN_STALE_SERVED label=TokenHelper valid_for_s=%.0f token_url=%s", + valid_for, + self._token_url, + ) + return self._access_token + def fetch_token_info(self, http: httpx.Client | None = None) -> tuple[str, float]: """Return ``(token, expires_at_monotonic)``.""" token = self.fetch_token(http) - return token, self._expires_at + with self._lock: + return token, self._expires_at + + def _refresh_guarded(self, http: httpx.Client | None) -> None: + """Refresh once unless the failure cooldown is still open. + + Callers must hold ``self._lock``. + """ + cooling_for = self._cooldown.remaining() + if cooling_for > 0: + raise TokenEndpointCoolingDownError( + f"Token endpoint is cooling down for {cooling_for:.0f}s after a " + f"recent failure; not retrying ({self._token_url})" + ) + + if http is not None: + self._refresh(http) + else: + with httpx.Client(timeout=30.0) as client: + self._refresh(client) def _refresh(self, http: httpx.Client) -> None: logger.info("Refreshing OAuth2 access token from %s", self._token_url) + data = {"grant_type": "client_credentials"} + if self._scope: + data["scope"] = self._scope try: response = http.post( self._token_url, - data={"grant_type": "client_credentials"}, + data=data, auth=(self._client_id, self._secret), + headers=self._request_headers, ) response.raise_for_status() except httpx.HTTPStatusError as exc: - raise AuthenticationError( - f"Token request failed: HTTP {exc.response.status_code}" + self._cooldown.start() + apply_rate_limit_headers(self._cooldown, exc.response, label="TokenHelper") + raise TokenRequestFailedError( + f"Token request failed: HTTP {exc.response.status_code}", + status_code=exc.response.status_code, + detail=error_detail(exc.response), + diagnostics=diagnostic_headers(exc.response.headers), ) from exc except httpx.HTTPError as exc: - raise AuthenticationError(f"Token request failed: {exc}") from exc + self._cooldown.start() + raise TokenRequestFailedError(f"Token request failed: {exc}") from exc - payload = response.json() - self._access_token = payload["access_token"] - expires_in = int(payload.get("expires_in", 3600)) - self._expires_at = time.monotonic() + expires_in + try: + payload = response.json() + access_token = parse_access_token(payload) + expires_in = parse_expires_in(payload) + except (ValueError, KeyError, TypeError) as exc: + # A malformed body would otherwise retry as hard as a denial. + self._cooldown.start() + apply_rate_limit_headers(self._cooldown, response, label="TokenHelper") + raise TokenRequestFailedError( + f"Token response was not usable: {exc}", + status_code=response.status_code, + detail=error_detail(response), + diagnostics=diagnostic_headers(response.headers), + ) from exc + + now = time.monotonic() + self._access_token = access_token + self._expires_at = now + expires_in + self._usable_until = now + max( + expires_in - self._refresh_margin, MIN_CACHE_LIFETIME_SECS + ) + self._cooldown.clear() + # After clear(): a granted token can still report a nearly spent + # shared budget, and the hold must survive the reset. + apply_rate_limit_headers(self._cooldown, response, label="TokenHelper") logger.info("Token acquired, expires in %ds", expires_in) diff --git a/skills/ess/summarize-change-log/LICENSE b/packages/python/ess-browser/LICENSE similarity index 100% rename from skills/ess/summarize-change-log/LICENSE rename to packages/python/ess-browser/LICENSE diff --git a/packages/python/ess-browser/README.md b/packages/python/ess-browser/README.md index eb2c85b6..ab488546 100644 --- a/packages/python/ess-browser/README.md +++ b/packages/python/ess-browser/README.md @@ -3,14 +3,20 @@ Shared Playwright browser session with SSO handling. ```python -from ess_browser import BrowserSession +from ess_browser import SsoSite, open_authenticated_page, should_use_headed_browser -with BrowserSession(headed=True) as session: - page = session.new_page() - page.goto("https://example.com/") - session.wait_for_login(page, "https://example.com/") +SITE = SsoSite(url="https://app.example.com/dashboard") +PROFILE = "~/.my-tool/browser-profile" + +headed = should_use_headed_browser(SITE, profile_dir=PROFILE) + +with open_authenticated_page(SITE, headed=headed, profile_dir=PROFILE) as page: + print(page.title()) ``` +The first run opens a window so you can complete SSO; later runs reuse +the saved session and stay headless. + ## Installation From the workspace root: @@ -21,6 +27,41 @@ uv sync --all-packages ## API +### `SsoSite` + +Frozen dataclass describing one SSO-protected site. Declare it once as a +module constant instead of threading the same keyword arguments through +every call. + +| Field | Default | Description | +|---|---|---| +| `url` | *required* | Page to open; its host means "authenticated" | +| `login_complete_url` | `None` | Where the IdP lands, when that is not `url` | +| `settle_ms` | `0` | Grace period before judging an SSO redirect | +| `nav_timeout_ms` | `60_000` | Navigation timeout | +| `extra_auth_domains` | `()` | IdP hosts beyond `AUTH_DOMAINS` | + +Set `settle_ms` for sites that redirect from client-side JavaScript, where +the first load briefly lands on the app before bouncing to the IdP. Set +`login_complete_url` when the callback always returns to one canonical +host, otherwise waiting on `url` never finishes. Use `extra_auth_domains` +for a tenant's own IdP rather than widening the shared tuple. + +### `open_authenticated_page(site, *, headed, profile_dir=None, prepare=None)` + +Context manager yielding a page that has completed SSO: it navigates, +waits out any IdP redirect, and returns to `site.url` if login landed +elsewhere. Raises `SsoTimeoutError` if login never completes. Pass +`prepare` to attach listeners before the first navigation, which would +otherwise miss that navigation's own traffic. + +### `should_use_headed_browser(site, *, profile_dir, force_headed=False, force_headless=False)` + +Probes the saved session and returns whether a visible window is needed. +Raises `ValueError` if both force flags are set. Callers that prefer a +`--headless` default with a retry on SSO expiry can skip this helper and +just retry `open_authenticated_page` headed when login times out. + ### `BrowserSession` Context manager that launches a persistent Chrome browser session. @@ -38,7 +79,8 @@ Methods: - `new_page() -> Page` -- open a new browser tab - `wait_for_login(page, target_url, timeout_ms=300_000)` -- block until SSO completes -- `is_auth_redirect(current_url, target_url) -> bool` -- check if on an SSO page +- `is_auth_redirect(current_url, target_url, *, extra_auth_domains=()) -> bool` -- + check if on an SSO page #### Duo prompt auto-dismiss @@ -53,12 +95,62 @@ Set `enable_extensions=True` to load extensions installed in the profile directory. This only has an effect in headed mode; Playwright ignores extensions when running headless. +### Profile directories + +- `default_profile_dir(tool_name) -> Path` -- `~/./browser-profile` +- `profile_dir_display(tool_name) -> str` -- the same path in tilde form, + for `--help` text that should not print the running user's name + +Each tool gets its own directory on purpose: one expired session should +not force a re-login everywhere. + +#### Session cookies + +`BrowserSession` carries session-scoped cookies across launches itself, +in `/session-cookies.json` (written `0600`, owner-only). + +This is not belt-and-braces. The cookie that represents a completed +SSO login, often a `JSESSIONID` on the IdP's host, is session-scoped, +and Chrome restores those only under "continue where you left off", +which Playwright does not set. Without this, a persistent profile keeps +everything *except* the thing that keeps you logged in, and every launch +repeats the IdP round trip. + +Only session-scoped cookies are stored. Anything with a real expiry is +already in Chrome's own store, and duplicating it would give one cookie +two sources of truth. Saved cookies older than 12 hours are dropped +rather than replayed, and every step is best effort: a failure here +costs one login, never the run. + +A tool that opens more than one browser per command still pays for a +login per launch, because the session cookie is only written on clean +exit. Prefer one session per run and share the context. + +### Exceptions + +- `EssBrowserError` -- base class for everything this package raises +- `SsoTimeoutError` -- login did not complete before the timeout +- `sso_timeout_message(*, headed, prefix=..., retry_hint=...)` -- builds the + message, appending the retry hint only when it is actionable. The hint + defaults to "re-run with `--headed`"; pass `retry_hint` if your CLI spells + that differently so operators are not sent to a flag you do not have + +Callers are expected to catch `SsoTimeoutError` and re-raise it as their +own domain error; the message already carries the actionable hint. + ### `ess_browser.auth` Lower-level SSO detection functions: -- `AUTH_DOMAINS` -- tuple of known SSO/IdP domain patterns +- `AUTH_DOMAINS` -- tuple of public SSO/IdP domain patterns. It names no + company's own login hosts; pass those as `extra_auth_domains` - `DUO_SKIP_UPDATE_SCRIPT` -- JS snippet that dismisses the Duo update prompt -- `LOGIN_TIMEOUT_MS` -- default timeout (5 minutes) -- `is_auth_redirect(current_url, target_url) -> bool` +- `LOGIN_TIMEOUT_MS` -- login wait timeout (5 minutes) +- `NAV_TIMEOUT_MS` -- default navigation timeout (60 seconds) +- `SSO_SETTLE_MS` -- default redirect grace period (5 seconds) +- `is_auth_redirect(current_url, target_url, *, extra_auth_domains=()) -> bool` +- `is_sso_login_required(target_url, *, profile_dir, ...) -> bool` - `wait_for_login(page, target_url, timeout_ms=LOGIN_TIMEOUT_MS)` + +`AUTH_DOMAINS` includes substring patterns (`sso.`, `idp.`, `adfs.`), so +host matching is deliberately loose. diff --git a/packages/python/ess-browser/pyproject.toml b/packages/python/ess-browser/pyproject.toml index 04105709..780cd37c 100644 --- a/packages/python/ess-browser/pyproject.toml +++ b/packages/python/ess-browser/pyproject.toml @@ -3,6 +3,7 @@ name = "ess-browser" version = "0.1.0" description = "Shared Playwright browser session with SSO handling" readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [ "playwright>=1.40.0", @@ -14,7 +15,7 @@ dev = [ ] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build] diff --git a/packages/python/ess-browser/src/ess_browser/__init__.py b/packages/python/ess-browser/src/ess_browser/__init__.py index 3ad11cf4..2332e9c1 100644 --- a/packages/python/ess-browser/src/ess_browser/__init__.py +++ b/packages/python/ess-browser/src/ess_browser/__init__.py @@ -4,17 +4,39 @@ AUTH_DOMAINS, DUO_SKIP_UPDATE_SCRIPT, LOGIN_TIMEOUT_MS, + NAV_TIMEOUT_MS, + SSO_SETTLE_MS, is_auth_redirect, + is_sso_login_required, wait_for_login, ) -from .session import DEFAULT_PROFILE_DIR, BrowserSession +from .authenticated_page import open_authenticated_page +from .exceptions import EssBrowserError, SsoTimeoutError, sso_timeout_message +from .session import ( + DEFAULT_PROFILE_DIR, + BrowserSession, + default_profile_dir, + profile_dir_display, +) +from .site import SsoSite, should_use_headed_browser __all__ = [ "AUTH_DOMAINS", "BrowserSession", "DEFAULT_PROFILE_DIR", "DUO_SKIP_UPDATE_SCRIPT", + "EssBrowserError", "LOGIN_TIMEOUT_MS", + "NAV_TIMEOUT_MS", + "SSO_SETTLE_MS", + "SsoSite", + "SsoTimeoutError", + "default_profile_dir", "is_auth_redirect", + "is_sso_login_required", + "open_authenticated_page", + "profile_dir_display", + "should_use_headed_browser", + "sso_timeout_message", "wait_for_login", ] diff --git a/packages/python/ess-browser/src/ess_browser/auth.py b/packages/python/ess-browser/src/ess_browser/auth.py index 6cd291f2..83a286bb 100644 --- a/packages/python/ess-browser/src/ess_browser/auth.py +++ b/packages/python/ess-browser/src/ess_browser/auth.py @@ -4,6 +4,7 @@ import logging import sys +from pathlib import Path from urllib.parse import urlparse from playwright.sync_api import Page @@ -11,11 +12,12 @@ logger = logging.getLogger(__name__) LOGIN_TIMEOUT_MS = 5 * 60 * 1000 # 5 minutes +SSO_SETTLE_MS = 5_000 +NAV_TIMEOUT_MS = 60_000 +# Public IdPs only. Callers pass a company's own login hosts as +# ``extra_auth_domains``. AUTH_DOMAINS: tuple[str, ...] = ( - # Cisco - "id.cisco.com", - "cloudsso.cisco.com", # Duo "duosecurity.com", "login.duosecurity.com", @@ -43,6 +45,7 @@ (() => { "use strict"; const hostname = location.hostname; + // Init script runs on every navigation; no-op outside Duo-hosted pages. if (hostname !== "duosecurity.com" && !hostname.endsWith(".duosecurity.com")) return; const SKIP_TEXT = "Skip for now"; @@ -99,13 +102,83 @@ """ -def is_auth_redirect(current_url: str, target_url: str) -> bool: - """Return True if the browser landed on an SSO page instead of the target.""" +def is_auth_redirect( + current_url: str, + target_url: str, + *, + extra_auth_domains: tuple[str, ...] = (), +) -> bool: + """Return True if the browser landed on an SSO page instead of the target. + + Args: + current_url: Where the browser actually is. + target_url: Where it was asked to go. + extra_auth_domains: Host patterns to recognize as IdPs in + addition to :data:`AUTH_DOMAINS`. Lets one caller teach this + check about a tenant-specific IdP without widening the + shared tuple for everybody. + """ current_host = urlparse(current_url).netloc.lower() target_host = urlparse(target_url).netloc.lower() if current_host == target_host: return False - return any(domain in current_host for domain in AUTH_DOMAINS) + domains = AUTH_DOMAINS + extra_auth_domains + return any(domain in current_host for domain in domains) + + +def _is_profile_empty(profile_dir: Path) -> bool: + if not profile_dir.is_dir(): + return True + return not any(profile_dir.iterdir()) + + +def is_sso_login_required( + target_url: str, + *, + profile_dir: Path | str, + settle_timeout_ms: int = SSO_SETTLE_MS, + extra_auth_domains: tuple[str, ...] = (), +) -> bool: + """Return True when a headless visit to ``target_url`` lands on SSO. + + Best-effort probe: navigation or browser errors are logged and treated as + ``False`` so callers can attempt headless execution and apply their own + SSO retry logic. + + Args: + target_url: The page to probe. + profile_dir: Browser profile directory holding the saved session. + settle_timeout_ms: Grace period before reading the landed URL. + extra_auth_domains: Additional IdP host patterns, passed through + to :func:`is_auth_redirect` so the probe agrees with the + real run. + """ + from ess_browser.session import BrowserSession # noqa: PLC0415, I001 — session imports auth; lazy import breaks cycle + + effective_profile = Path(profile_dir) + + try: + # No persisted cookies yet — skip the probe and require headed login once. + if _is_profile_empty(effective_profile): + return True + + with BrowserSession(headed=False, profile_dir=effective_profile) as session: + page = session.new_page() + page.goto(target_url, wait_until="load", timeout=NAV_TIMEOUT_MS) + page.wait_for_timeout(settle_timeout_ms) + return is_auth_redirect( + page.url, + target_url, + extra_auth_domains=extra_auth_domains, + ) + except Exception as exc: + logger.warning( + "SSO probe failed for %s (profile %s): %s — assuming session is valid", + target_url, + effective_profile, + exc, + ) + return False def wait_for_login( diff --git a/packages/python/ess-browser/src/ess_browser/authenticated_page.py b/packages/python/ess-browser/src/ess_browser/authenticated_page.py new file mode 100644 index 00000000..b010d9db --- /dev/null +++ b/packages/python/ess-browser/src/ess_browser/authenticated_page.py @@ -0,0 +1,80 @@ +"""Authenticated page sessions for SSO-protected sites.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from pathlib import Path + +from playwright.sync_api import Page +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError + +from .auth import LOGIN_TIMEOUT_MS, is_auth_redirect, wait_for_login +from .exceptions import SsoTimeoutError, sso_timeout_message +from .session import BrowserSession +from .site import SsoSite + +logger = logging.getLogger(__name__) + + +@contextmanager +def open_authenticated_page( + site: SsoSite, + *, + headed: bool, + profile_dir: Path | str | None = None, + prepare: Callable[[Page], None] | None = None, +) -> Iterator[Page]: + """Open *site* in a browser tab that has completed SSO. + + Reuses the saved session in *profile_dir* when it is still valid. + When it is not, and the window is visible, this blocks until the + user finishes logging in. + + Args: + site: The site to open, including its SSO quirks. + headed: Show the browser window. + profile_dir: Browser profile directory for session persistence. + prepare: Called with the blank page before the first navigation. + Use it to attach listeners that would otherwise miss the + traffic that navigation itself generates. + + Yields: + A Playwright page sitting on an authenticated URL of *site*. + + Raises: + SsoTimeoutError: Login did not complete within the timeout. + """ + with BrowserSession(headed=headed, profile_dir=profile_dir) as browser: + page = browser.new_page() + if prepare is not None: + prepare(page) + logger.info("Navigating to %s", site.url) + page.goto(site.url, wait_until="load", timeout=site.nav_timeout_ms) + if site.settle_ms: + page.wait_for_timeout(site.settle_ms) + _complete_login(page, site, headed=headed) + yield page + + +def _complete_login(page: Page, site: SsoSite, *, headed: bool) -> None: + """Wait out an SSO redirect, then return to the requested page.""" + if not is_auth_redirect( + page.url, + site.url, + extra_auth_domains=site.extra_auth_domains, + ): + return + + logger.info("SSO redirect detected -- waiting for login") + try: + wait_for_login(page, site.login_target, timeout_ms=LOGIN_TIMEOUT_MS) + except PlaywrightTimeoutError as exc: + raise SsoTimeoutError(sso_timeout_message(headed=headed)) from exc + + # Login ends on the IdP callback landing page, which is not + # necessarily the page that was asked for. + if not page.url.startswith(site.url): + logger.info("Returning to %s after login", site.url) + page.goto(site.url, wait_until="load", timeout=site.nav_timeout_ms) diff --git a/packages/python/ess-browser/src/ess_browser/exceptions.py b/packages/python/ess-browser/src/ess_browser/exceptions.py new file mode 100644 index 00000000..88cf9a5b --- /dev/null +++ b/packages/python/ess-browser/src/ess_browser/exceptions.py @@ -0,0 +1,37 @@ +"""Domain exceptions for browser sessions and SSO flows.""" + +from __future__ import annotations + +HEADED_HINT = " Re-run with --headed to complete login in the browser." + + +class EssBrowserError(Exception): + """Base class for every error raised by this package.""" + + +class SsoTimeoutError(EssBrowserError): + """SSO did not complete before the login timeout elapsed.""" + + +def sso_timeout_message( + *, + headed: bool, + prefix: str = "SSO authentication timed out", + retry_hint: str = HEADED_HINT, +) -> str: + """Build the message for an SSO login that never completed. + + Args: + headed: Whether the browser window was already visible. The retry + hint is appended only when it was not, because an interactive + login cannot be completed in a headless window. + prefix: Leading sentence, so a caller can name its own IdP. + retry_hint: How *this* caller's CLI asks for a visible window. + Defaults to ``--headed``; a tool spelling it differently must + pass its own, or it sends operators to a flag it lacks. + + Returns: + The message, with the retry hint appended only when actionable. + """ + hint = "" if headed else retry_hint + return f"{prefix}.{hint}" diff --git a/packages/python/ess-browser/src/ess_browser/session.py b/packages/python/ess-browser/src/ess_browser/session.py index e98f971e..dbf943f7 100644 --- a/packages/python/ess-browser/src/ess_browser/session.py +++ b/packages/python/ess-browser/src/ess_browser/session.py @@ -2,7 +2,12 @@ from __future__ import annotations +import json import logging +import math +import os +import tempfile +import time from pathlib import Path from types import TracebackType from urllib.parse import urlparse @@ -21,6 +26,49 @@ _TARGET_CLOSED_MESSAGE = "Target page, context or browser has been closed" +# Session cookies carried across launches by hand, because nothing else +# will. The cookie that represents a completed SSO login (often a +# `JSESSIONID` on the IdP's host) is session-scoped, and Chrome restores +# those only under "continue where you left off", which Playwright does +# not set. Without this, every launch starts logged out and re-runs the +# IdP round trip even though the profile is persistent. +# +# Only session-scoped cookies are written here. Anything with a real +# expiry already lives in Chrome's own store, and duplicating it would +# mean two sources of truth for the same cookie. +_SESSION_COOKIE_FILE = "session-cookies.json" + +# Playwright's marker for "expires when the browser closes". +_SESSION_COOKIE_EXPIRY = -1 + +# Past this, a saved session is assumed dead and dropped rather than +# replayed. IdP sessions typically last a few hours, so a stale cookie +# would only fail -- but sending an old session identifier +# to the IdP is worth avoiding regardless. +_SESSION_COOKIE_MAX_AGE_S = 12 * 60 * 60 + +# Owner-only: these are live credentials for the duration of a session. +_SESSION_COOKIE_MODE = 0o600 + + +def default_profile_dir(tool_name: str) -> Path: + """Return the conventional profile directory for *tool_name*. + + Every consumer stores its session under ``~/./browser-profile``. + Separate directories per tool are deliberate: one expired session + should not force a re-login everywhere. + """ + return Path.home() / f".{tool_name}" / "browser-profile" + + +def profile_dir_display(tool_name: str) -> str: + """Return the tilde form of :func:`default_profile_dir` for help text. + + Expanding the home directory in ``--help`` output bakes whichever + username happens to run the command into docs and bug reports. + """ + return f"~/.{tool_name}/browser-profile" + def _is_target_closed(exc: PlaywrightError) -> bool: """True when *exc* signals a closed page/context/browser. @@ -112,6 +160,86 @@ def _evaluate_on_existing_pages( ) +def _write_session_cookies(path: Path, cookies: list[dict]) -> None: + """Write *cookies* to *path*, owner-readable only, atomically. + + Uses an exclusive temporary file (``mkstemp``) so a leftover + ``session-cookies.json.tmp`` from a crash is never truncated and + reused. ``mkstemp`` creates the file at ``0o600``; we chmod anyway + so the mode is explicit. + """ + payload = {"saved_at": time.time(), "cookies": cookies} + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, tmp_name = tempfile.mkstemp( + prefix=f"{path.name}.", + suffix=".tmp", + dir=path.parent, + ) + tmp = Path(tmp_name) + replaced = False + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(payload, handle) + os.chmod(tmp, _SESSION_COOKIE_MODE) + tmp.replace(path) + replaced = True + finally: + if not replaced: + tmp.unlink(missing_ok=True) + + +def _saved_at(payload: dict) -> float | None: + """Return a finite save time, or nothing when the metadata is unusable. + + ``float("NaN")`` and ``float("inf")`` do not raise, and both fail the + age comparison in the direction that would keep the cookies forever. + """ + try: + saved_at = float(payload.get("saved_at") or 0) + except (TypeError, ValueError, OverflowError): + return None + if not math.isfinite(saved_at): + return None + return saved_at + + +def _read_session_cookies(path: Path) -> list[dict]: + """Return saved session cookies, or nothing when they cannot be used. + + A missing, unreadable, malformed, or stale file all mean the same + thing to the caller: start without them and let SSO run. + """ + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return [] + except (OSError, ValueError): + logger.debug("Saved session cookies are unreadable; ignoring them") + return [] + + if not isinstance(payload, dict): + return [] + + saved_at = _saved_at(payload) + if saved_at is None: + logger.debug("Saved session cookies have invalid metadata; ignoring them") + return [] + + age = time.time() - saved_at + if age > _SESSION_COOKIE_MAX_AGE_S: + logger.debug("Saved session cookies are stale (%.0fs old); dropping", age) + try: + path.unlink(missing_ok=True) + except OSError as exc: + logger.debug( + "Could not drop stale session cookies (%s)", type(exc).__name__ + ) + return [] + + cookies = payload.get("cookies") + return cookies if isinstance(cookies, list) else [] + + class BrowserSession: """Context manager for a persistent Chrome browser session. @@ -179,6 +307,7 @@ def __enter__(self) -> BrowserSession: for script in all_scripts: self._context.add_init_script(script) + self._restore_session_cookies() _evaluate_on_existing_pages(self._context.pages, all_scripts) except Exception: if self._pw is not None: @@ -196,6 +325,8 @@ def __exit__( exc_tb: TracebackType | None, ) -> None: try: + # Before close: the cookies are gone once the context is. + self._save_session_cookies() if self._context: self._context.close() finally: @@ -204,6 +335,59 @@ def __exit__( self._context = None self._pw = None + @property + def _session_cookie_path(self) -> Path: + """Where this profile's session cookies are parked between runs.""" + return self._profile / _SESSION_COOKIE_FILE + + def _restore_session_cookies(self) -> None: + """Re-add the session cookies saved by the previous run. + + Best effort throughout: a failure here costs one SSO login, so + it must never stop the session from opening. + """ + cookies = _read_session_cookies(self._session_cookie_path) + if not cookies or self._context is None: + return + try: + self._context.add_cookies(cookies) + except (PlaywrightError, ValueError) as exc: + logger.debug("Could not restore session cookies (%s)", type(exc).__name__) + return + logger.debug("Restored %d session cookie(s)", len(cookies)) + + def _save_session_cookies(self) -> None: + """Save session-scoped cookies so the next run stays logged in.""" + if self._context is None: + return + try: + cookies = [ + cookie + for cookie in self._context.cookies() + if cookie.get("expires") == _SESSION_COOKIE_EXPIRY + ] + except PlaywrightError as exc: + logger.debug("Could not read cookies to save (%s)", type(exc).__name__) + return + + path = self._session_cookie_path + if not cookies: + # Nothing to carry over, and a leftover file from an earlier + # run would outlive the session it belongs to. A failed delete + # must not fail the session close. + try: + path.unlink(missing_ok=True) + except OSError as exc: + logger.debug("Could not clear session cookies (%s)", type(exc).__name__) + return + + try: + _write_session_cookies(path, cookies) + except OSError as exc: + logger.debug("Could not save session cookies (%s)", type(exc).__name__) + return + logger.debug("Saved %d session cookie(s)", len(cookies)) + @property def context(self) -> BrowserContext: """The underlying Playwright browser context.""" @@ -217,9 +401,25 @@ def new_page(self) -> Page: return self.context.new_page() @staticmethod - def is_auth_redirect(current_url: str, target_url: str) -> bool: - """Check if the browser is on an SSO page instead of the target.""" - return is_auth_redirect(current_url, target_url) + def is_auth_redirect( + current_url: str, + target_url: str, + *, + extra_auth_domains: tuple[str, ...] = (), + ) -> bool: + """Check if the browser is on an SSO page instead of the target. + + Args: + current_url: Where the browser actually is. + target_url: Where it was asked to go. + extra_auth_domains: IdP host patterns to recognize in addition + to :data:`~ess_browser.AUTH_DOMAINS`. + """ + return is_auth_redirect( + current_url, + target_url, + extra_auth_domains=extra_auth_domains, + ) @staticmethod def wait_for_login( diff --git a/packages/python/ess-browser/src/ess_browser/site.py b/packages/python/ess-browser/src/ess_browser/site.py new file mode 100644 index 00000000..757168ea --- /dev/null +++ b/packages/python/ess-browser/src/ess_browser/site.py @@ -0,0 +1,83 @@ +"""Per-site SSO facts and headed-mode selection.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from .auth import NAV_TIMEOUT_MS, SSO_SETTLE_MS, is_sso_login_required + + +@dataclass(frozen=True) +class SsoSite: + """The auth-relevant facts about one SSO-protected site. + + Declare one per site as a module constant and hand it to + :func:`ess_browser.open_authenticated_page`. Collecting the facts in + one object is what keeps each consumer from threading the same + handful of keyword arguments through its own session helper. + + Attributes: + url: The page to open. Its host is also what "authenticated" + means for this site. + login_complete_url: Where the IdP lands after a successful login, + when that is not ``url``. An OIDC callback may always return + to one canonical host regardless of which URL began the flow, + in which case waiting on ``url`` would wait forever. + settle_ms: Grace period after load before judging whether this is + an SSO redirect. Needed for sites that redirect from + client-side JavaScript rather than an HTTP response, where + the first load lands on the app briefly before bouncing. + nav_timeout_ms: Navigation timeout for this site. + extra_auth_domains: IdP host patterns to recognize in addition to + :data:`ess_browser.AUTH_DOMAINS`. Name a tenant's own IdP + here rather than widening the shared tuple. + """ + + url: str + login_complete_url: str | None = None + settle_ms: int = 0 + nav_timeout_ms: int = NAV_TIMEOUT_MS + extra_auth_domains: tuple[str, ...] = () + + @property + def login_target(self) -> str: + """The URL that signals login is complete.""" + return self.login_complete_url or self.url + + +def should_use_headed_browser( + site: SsoSite, + *, + profile_dir: Path | str, + force_headed: bool = False, + force_headless: bool = False, +) -> bool: + """Decide whether the browser window needs to be visible. + + Args: + site: The site whose saved session is probed. + profile_dir: Browser profile directory holding that session. + force_headed: Always show the window. + force_headless: Never show the window. + + Returns: + True when a visible window is needed, either because it was + forced or because the saved SSO session has expired. + + Raises: + ValueError: Both force flags were set. + """ + if force_headed and force_headless: + msg = "Cannot pass both --headed and --headless" + raise ValueError(msg) + if force_headed: + return True + if force_headless: + return False + return is_sso_login_required( + site.url, + profile_dir=profile_dir, + settle_timeout_ms=site.settle_ms or SSO_SETTLE_MS, + extra_auth_domains=site.extra_auth_domains, + ) diff --git a/packages/python/ess-browser/src/ess_browser/test_auth.py b/packages/python/ess-browser/src/ess_browser/test_auth.py index 3dfdc7d7..b06ead78 100644 --- a/packages/python/ess-browser/src/ess_browser/test_auth.py +++ b/packages/python/ess-browser/src/ess_browser/test_auth.py @@ -2,7 +2,11 @@ from __future__ import annotations -from .auth import DUO_SKIP_UPDATE_SCRIPT, is_auth_redirect +from pathlib import Path +from unittest.mock import MagicMock, patch + +from .auth import DUO_SKIP_UPDATE_SCRIPT, is_auth_redirect, is_sso_login_required +from .session import BrowserSession class TestIsAuthRedirect: @@ -30,6 +34,25 @@ def test_unrelated_host_is_not_redirect(self): "https://app.example.com/", ) + def test_company_idp_needs_extra_auth_domains(self): + # Company login hosts belong to the caller, not to AUTH_DOMAINS. + assert not is_auth_redirect( + "https://login.corp.example.net/authorize", + "https://app.example.com/", + ) + assert is_auth_redirect( + "https://login.corp.example.net/authorize", + "https://app.example.com/", + extra_auth_domains=("login.corp.example.net",), + ) + + def test_browser_session_wrapper_honors_extra_auth_domains(self): + assert BrowserSession.is_auth_redirect( + "https://login.corp.example.net/authorize", + "https://app.example.com/", + extra_auth_domains=("login.corp.example.net",), + ) + class TestDuoSkipUpdateScript: """Verify the domain guard in the JS script.""" @@ -40,3 +63,79 @@ def test_rejects_evil_subdomain(self): def test_uses_safe_observer_root(self): assert "document.body || document.documentElement" in DUO_SKIP_UPDATE_SCRIPT + + +class TestIsSsoLoginRequired: + @patch("ess_browser.session.BrowserSession") + def test_missing_profile_dir_requires_login( + self, mock_session_cls: MagicMock, tmp_path: Path + ) -> None: + profile_dir = tmp_path / "nonexistent-ess-browser-profile-test" + + assert is_sso_login_required( + "https://app.example.com/", + profile_dir=profile_dir, + ) + mock_session_cls.assert_not_called() + + @patch("ess_browser.session.BrowserSession") + def test_auth_redirect_requires_login( + self, mock_session_cls: MagicMock, tmp_path: Path + ) -> None: + mock_session = MagicMock() + mock_session_cls.return_value.__enter__.return_value = mock_session + mock_page = MagicMock() + mock_session.new_page.return_value = mock_page + mock_page.url = "https://login.duosecurity.com/frame" + + profile_dir = tmp_path / "ess-browser-profile-with-data" + with patch("ess_browser.auth._is_profile_empty", return_value=False): + assert is_sso_login_required( + "https://app.example.com/", + profile_dir=profile_dir, + ) + + @patch("ess_browser.session.BrowserSession") + def test_valid_session_does_not_require_login( + self, mock_session_cls: MagicMock, tmp_path: Path + ) -> None: + mock_session = MagicMock() + mock_session_cls.return_value.__enter__.return_value = mock_session + mock_page = MagicMock() + mock_session.new_page.return_value = mock_page + mock_page.url = "https://app.example.com/dashboard" + + profile_dir = tmp_path / "ess-browser-profile-with-data" + with patch("ess_browser.auth._is_profile_empty", return_value=False): + assert not is_sso_login_required( + "https://app.example.com/", + profile_dir=profile_dir, + ) + + @patch("ess_browser.session.BrowserSession") + def test_probe_failure_assumes_session_valid( + self, mock_session_cls: MagicMock, tmp_path: Path + ) -> None: + mock_session_cls.side_effect = RuntimeError("Timeout 30000ms exceeded") + + profile_dir = tmp_path / "ess-browser-profile-with-data" + with patch("ess_browser.auth._is_profile_empty", return_value=False): + assert not is_sso_login_required( + "https://app.example.com/", + profile_dir=profile_dir, + ) + + @patch("ess_browser.session.BrowserSession") + def test_unreadable_profile_assumes_session_valid( + self, mock_session_cls: MagicMock, tmp_path: Path + ) -> None: + profile_dir = tmp_path / "ess-browser-profile-with-data" + with patch( + "ess_browser.auth._is_profile_empty", + side_effect=OSError("Permission denied"), + ): + assert not is_sso_login_required( + "https://app.example.com/", + profile_dir=profile_dir, + ) + mock_session_cls.assert_not_called() diff --git a/packages/python/ess-browser/src/ess_browser/test_authenticated_page.py b/packages/python/ess-browser/src/ess_browser/test_authenticated_page.py new file mode 100644 index 00000000..95811e89 --- /dev/null +++ b/packages/python/ess-browser/src/ess_browser/test_authenticated_page.py @@ -0,0 +1,214 @@ +"""Unit tests for the authenticated page context manager.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError + +from .authenticated_page import open_authenticated_page +from .exceptions import SsoTimeoutError, sso_timeout_message +from .site import SsoSite + +APP_URL = "https://app.example.com/" +IDP_URL = "https://sso.example.com/login" + +# Deliberately matches no AUTH_DOMAINS entry -- note that the generic +# "idp." pattern would swallow any host containing it, which would make +# the extra_auth_domains tests below pass for the wrong reason. +TENANT_IDP_URL = "https://auth.tenant.example.com/authorize" + +# One navigation to arrive, then a second to return to the requested page +# after the IdP callback dropped the browser somewhere else. +NAVIGATIONS_WITH_RETURN_TRIP = 2 + + +@pytest.fixture +def page() -> MagicMock: + mock_page = MagicMock() + mock_page.url = APP_URL + return mock_page + + +@pytest.fixture +def session(page: MagicMock): + """Patch BrowserSession so no real Chrome is launched.""" + with patch("ess_browser.authenticated_page.BrowserSession") as session_cls: + session_cls.return_value.__enter__.return_value.new_page.return_value = page + yield session_cls + + +class TestHappyPath: + def test_yields_the_page_without_waiting_when_already_signed_in( + self, session: MagicMock, page: MagicMock + ) -> None: + site = SsoSite(url=APP_URL) + + with patch("ess_browser.authenticated_page.wait_for_login") as wait: + with open_authenticated_page(site, headed=False) as yielded: + assert yielded is page + + wait.assert_not_called() + page.goto.assert_called_once_with( + APP_URL, wait_until="load", timeout=site.nav_timeout_ms + ) + + def test_prepare_runs_before_the_first_navigation( + self, session: MagicMock, page: MagicMock + ) -> None: + """Listeners attached after goto would miss the navigation traffic.""" + calls: list[str] = [] + page.goto.side_effect = lambda *a, **k: calls.append("goto") + + with open_authenticated_page( + SsoSite(url=APP_URL), + headed=False, + prepare=lambda _page: calls.append("prepare"), + ): + pass + + assert calls == ["prepare", "goto"] + + def test_settle_wait_only_happens_when_configured( + self, session: MagicMock, page: MagicMock + ) -> None: + with open_authenticated_page(SsoSite(url=APP_URL), headed=False): + pass + page.wait_for_timeout.assert_not_called() + + with open_authenticated_page( + SsoSite(url=APP_URL, settle_ms=1_500), headed=False + ): + pass + page.wait_for_timeout.assert_called_once_with(1_500) + + +class TestSsoRedirect: + def test_waits_for_login_then_returns_to_the_target( + self, session: MagicMock, page: MagicMock + ) -> None: + page.url = IDP_URL + site = SsoSite(url=APP_URL) + + with patch("ess_browser.authenticated_page.wait_for_login") as wait: + with open_authenticated_page(site, headed=True): + pass + + wait.assert_called_once() + assert wait.call_args[0][1] == APP_URL + # Still on the IdP host after login, so a second goto is required. + assert page.goto.call_count == NAVIGATIONS_WITH_RETURN_TRIP + + def test_waits_on_the_callback_host_not_the_target( + self, session: MagicMock, page: MagicMock + ) -> None: + """A callback that always lands elsewhere would otherwise hang.""" + page.url = IDP_URL + site = SsoSite( + url="https://alias.example.com/goals", + login_complete_url=APP_URL, + ) + + with patch("ess_browser.authenticated_page.wait_for_login") as wait: + with open_authenticated_page(site, headed=True): + pass + + assert wait.call_args[0][1] == APP_URL + + def test_no_second_navigation_when_login_lands_on_target( + self, session: MagicMock, page: MagicMock + ) -> None: + page.url = IDP_URL + site = SsoSite(url=APP_URL) + + def land_on_target(*_args: object, **_kwargs: object) -> None: + page.url = f"{APP_URL}dashboard" + + with patch( + "ess_browser.authenticated_page.wait_for_login", + side_effect=land_on_target, + ): + with open_authenticated_page(site, headed=True): + pass + + assert page.goto.call_count == 1 + + def test_extra_auth_domains_are_honored( + self, session: MagicMock, page: MagicMock + ) -> None: + """A tenant IdP unknown to AUTH_DOMAINS must still be detected.""" + page.url = TENANT_IDP_URL + site = SsoSite(url=APP_URL, extra_auth_domains=("tenant.example.com",)) + + with patch("ess_browser.authenticated_page.wait_for_login") as wait: + with open_authenticated_page(site, headed=True): + pass + + wait.assert_called_once() + + def test_unknown_idp_host_is_not_detected_without_extras( + self, session: MagicMock, page: MagicMock + ) -> None: + """Guards the test above: without extras this host is invisible.""" + page.url = TENANT_IDP_URL + + with patch("ess_browser.authenticated_page.wait_for_login") as wait: + with open_authenticated_page(SsoSite(url=APP_URL), headed=True): + pass + + wait.assert_not_called() + + +class TestTimeout: + def test_playwright_timeout_becomes_sso_timeout_error( + self, session: MagicMock, page: MagicMock + ) -> None: + page.url = IDP_URL + + with patch( + "ess_browser.authenticated_page.wait_for_login", + side_effect=PlaywrightTimeoutError("Timeout 300000ms exceeded"), + ): + with pytest.raises(SsoTimeoutError) as excinfo: + with open_authenticated_page(SsoSite(url=APP_URL), headed=False): + pass + + assert "--headed" in str(excinfo.value) + + def test_headed_timeout_omits_the_retry_hint( + self, session: MagicMock, page: MagicMock + ) -> None: + page.url = IDP_URL + + with patch( + "ess_browser.authenticated_page.wait_for_login", + side_effect=PlaywrightTimeoutError("Timeout 300000ms exceeded"), + ): + with pytest.raises(SsoTimeoutError) as excinfo: + with open_authenticated_page(SsoSite(url=APP_URL), headed=True): + pass + + assert "--headed" not in str(excinfo.value) + + +class TestSsoTimeoutMessage: + def test_headless_gets_an_actionable_hint(self) -> None: + assert "--headed" in sso_timeout_message(headed=False) + + def test_a_caller_can_name_its_own_option(self) -> None: + """Not every CLI spells this `--headed`; sending them there misleads.""" + message = sso_timeout_message( + headed=False, + retry_hint=" Re-run with --browser headed.", + ) + assert "--browser headed" in message + assert "--headed to complete" not in message + + def test_headed_gets_no_hint(self) -> None: + assert sso_timeout_message(headed=True) == "SSO authentication timed out." + + def test_prefix_is_overridable(self) -> None: + message = sso_timeout_message(headed=True, prefix="Tenant SSO timed out") + + assert message == "Tenant SSO timed out." diff --git a/packages/python/ess-browser/src/ess_browser/test_session.py b/packages/python/ess-browser/src/ess_browser/test_session.py index 5783c651..eff0ff9f 100644 --- a/packages/python/ess-browser/src/ess_browser/test_session.py +++ b/packages/python/ess-browser/src/ess_browser/test_session.py @@ -2,13 +2,15 @@ from __future__ import annotations +import json +import time from unittest.mock import MagicMock, PropertyMock, call, patch import pytest from playwright.sync_api import Error as PlaywrightError from .auth import DUO_SKIP_UPDATE_SCRIPT -from .session import BrowserSession, _safe_origin +from .session import _SESSION_COOKIE_MODE, BrowserSession, _safe_origin class _TargetClosedError(PlaywrightError): @@ -288,10 +290,16 @@ def test_strips_query_and_path(self): ) def test_strips_userinfo(self): - assert _safe_origin("https://alice:x@host.com/p") == ("https://host.com") + # The credentials are fake; stripping them is what this test checks. + # secretlint-disable-next-line @secretlint/secretlint-rule-basicauth + assert _safe_origin("https://alice:x@host.example.com/p") == ( + "https://host.example.com" + ) def test_preserves_port(self): - assert _safe_origin("https://host.com:8443/p") == ("https://host.com:8443") + assert _safe_origin("https://host.example.com:8443/p") == ( + "https://host.example.com:8443" + ) def test_file_url_returns_scheme_only(self): assert _safe_origin("file:///etc/passwd") == "file:" @@ -300,7 +308,10 @@ def test_data_url_returns_scheme_only(self): assert _safe_origin("data:text/html,

hi

") == "data:" def test_malformed_port_does_not_raise(self): - assert _safe_origin("https://host:bad/path") == "https://host" + assert ( + _safe_origin("https://host.example.com:bad/path") + == "https://host.example.com" + ) def test_ipv6_brackets_preserved(self): assert _safe_origin("https://[::1]:8443/path") == "https://[::1]:8443" @@ -316,3 +327,200 @@ def test_scheme_relative_url_with_port(self): def test_empty_string(self): assert _safe_origin("") == "" + + +class TestSessionCookiePersistence: + """Carrying the SSO session cookie across launches. + + The cookie that represents a completed SSO login is + session-scoped, and Playwright's persistent context does not restore + those -- so without this the profile persists everything *except* + the thing that keeps you logged in. + """ + + _SESSION_COOKIE = { + "name": "JSESSIONID", + "value": "opaque", + "domain": "idp.example.com", + "path": "/", + "expires": -1, + } + _PERSISTENT_COOKIE = { + "name": "DT", + "value": "opaque", + "domain": "idp.example.com", + "path": "/", + "expires": 1893456000, + } + + def _cookie_file(self, tmp_path): + return tmp_path / "profile" / "session-cookies.json" + + def test_session_cookies_are_saved_on_exit(self, _mock_playwright, tmp_path): + _mock_playwright["context"].cookies.return_value = [ + self._SESSION_COOKIE, + self._PERSISTENT_COOKIE, + ] + with BrowserSession(profile_dir=tmp_path / "profile"): + pass + + saved = json.loads(self._cookie_file(tmp_path).read_text(encoding="utf-8")) + assert [c["name"] for c in saved["cookies"]] == ["JSESSIONID"] + + def test_persistent_cookies_are_left_to_chrome(self, _mock_playwright, tmp_path): + """Saving them too would make two stores disagree about one cookie.""" + _mock_playwright["context"].cookies.return_value = [self._PERSISTENT_COOKIE] + with BrowserSession(profile_dir=tmp_path / "profile"): + pass + + assert not self._cookie_file(tmp_path).exists() + + def test_saved_cookies_are_owner_only(self, _mock_playwright, tmp_path): + """They are live credentials for as long as the session lasts.""" + _mock_playwright["context"].cookies.return_value = [self._SESSION_COOKIE] + with BrowserSession(profile_dir=tmp_path / "profile"): + pass + + mode = self._cookie_file(tmp_path).stat().st_mode & 0o777 + assert mode == _SESSION_COOKIE_MODE + + def test_saved_cookies_are_restored_on_the_next_launch( + self, _mock_playwright, tmp_path + ): + _mock_playwright["context"].cookies.return_value = [self._SESSION_COOKIE] + with BrowserSession(profile_dir=tmp_path / "profile"): + pass + + _mock_playwright["context"].add_cookies.reset_mock() + with BrowserSession(profile_dir=tmp_path / "profile"): + pass + + _mock_playwright["context"].add_cookies.assert_called_once_with( + [self._SESSION_COOKIE] + ) + + def test_stale_cookies_are_dropped_not_replayed(self, _mock_playwright, tmp_path): + """An old session identifier should not be sent to the IdP.""" + path = self._cookie_file(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + {"saved_at": time.time() - 48 * 3600, "cookies": [self._SESSION_COOKIE]} + ), + encoding="utf-8", + ) + _mock_playwright["context"].cookies.return_value = [] + + with BrowserSession(profile_dir=tmp_path / "profile"): + pass + + _mock_playwright["context"].add_cookies.assert_not_called() + assert not path.exists() + + @pytest.mark.parametrize("saved_at", [float("nan"), float("inf")]) + def test_non_finite_saved_at_is_not_replayed( + self, _mock_playwright, tmp_path, saved_at + ): + """NaN and inf pass float() and then fail the age check the wrong way.""" + path = self._cookie_file(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"saved_at": saved_at, "cookies": [self._SESSION_COOKIE]}), + encoding="utf-8", + ) + _mock_playwright["context"].cookies.return_value = [] + + with BrowserSession(profile_dir=tmp_path / "profile") as session: + assert session.context is _mock_playwright["context"] + + _mock_playwright["context"].add_cookies.assert_not_called() + + def test_a_failed_cookie_delete_does_not_stop_the_session( + self, _mock_playwright, tmp_path + ): + """A read-only profile costs a login, not the session.""" + path = self._cookie_file(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + {"saved_at": time.time() - 48 * 3600, "cookies": [self._SESSION_COOKIE]} + ), + encoding="utf-8", + ) + _mock_playwright["context"].cookies.return_value = [] + + with patch.object(type(path), "unlink", side_effect=OSError("read-only")): + with BrowserSession(profile_dir=tmp_path / "profile") as session: + assert session.context is _mock_playwright["context"] + + _mock_playwright["context"].add_cookies.assert_not_called() + + def test_a_corrupt_file_does_not_stop_the_session(self, _mock_playwright, tmp_path): + """One bad file should cost a login, not the whole run.""" + path = self._cookie_file(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{not json", encoding="utf-8") + _mock_playwright["context"].cookies.return_value = [] + + with BrowserSession(profile_dir=tmp_path / "profile") as session: + assert session.context is _mock_playwright["context"] + + _mock_playwright["context"].add_cookies.assert_not_called() + + def test_a_logged_out_session_clears_the_saved_cookies( + self, _mock_playwright, tmp_path + ): + """Otherwise a stale file outlives the session it belongs to.""" + path = self._cookie_file(tmp_path) + _mock_playwright["context"].cookies.return_value = [self._SESSION_COOKIE] + with BrowserSession(profile_dir=tmp_path / "profile"): + pass + assert path.exists() + + _mock_playwright["context"].cookies.return_value = [] + with BrowserSession(profile_dir=tmp_path / "profile"): + pass + assert not path.exists() + + def test_no_temp_file_is_left_behind(self, _mock_playwright, tmp_path): + _mock_playwright["context"].cookies.return_value = [self._SESSION_COOKIE] + with BrowserSession(profile_dir=tmp_path / "profile"): + pass + + leftovers = list((tmp_path / "profile").glob("*.tmp")) + assert leftovers == [] + + def test_a_leftover_tmp_file_is_not_reused(self, _mock_playwright, tmp_path): + """A crash leftover must not receive live cookies at a world-readable mode.""" + profile = tmp_path / "profile" + profile.mkdir(parents=True, exist_ok=True) + leftover = profile / "session-cookies.json.tmp" + leftover.write_text("stale", encoding="utf-8") + leftover.chmod(0o644) + _mock_playwright["context"].cookies.return_value = [self._SESSION_COOKIE] + + with BrowserSession(profile_dir=profile): + pass + + dest = self._cookie_file(tmp_path) + assert dest.exists() + mode = dest.stat().st_mode & 0o777 + assert mode == _SESSION_COOKIE_MODE + # The leftover path was never truncated into the destination. + assert leftover.read_text(encoding="utf-8") == "stale" + + def test_non_numeric_saved_at_does_not_stop_the_session( + self, _mock_playwright, tmp_path + ): + path = self._cookie_file(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"saved_at": "corrupt", "cookies": [self._SESSION_COOKIE]}), + encoding="utf-8", + ) + _mock_playwright["context"].cookies.return_value = [] + + with BrowserSession(profile_dir=tmp_path / "profile") as session: + assert session.context is _mock_playwright["context"] + + _mock_playwright["context"].add_cookies.assert_not_called() diff --git a/packages/python/ess-browser/src/ess_browser/test_site.py b/packages/python/ess-browser/src/ess_browser/test_site.py new file mode 100644 index 00000000..f95f3642 --- /dev/null +++ b/packages/python/ess-browser/src/ess_browser/test_site.py @@ -0,0 +1,117 @@ +"""Unit tests for SsoSite and headed-mode selection.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from .auth import NAV_TIMEOUT_MS +from .session import default_profile_dir, profile_dir_display +from .site import SsoSite, should_use_headed_browser + +SETTLE_MS = 1_500 +TENANT_IDP_DOMAIN = "tenant.example.com" + + +class TestSsoSite: + def test_login_target_defaults_to_url(self) -> None: + site = SsoSite(url="https://app.example.com/") + + assert site.login_target == "https://app.example.com/" + + def test_login_target_prefers_explicit_callback_host(self) -> None: + site = SsoSite( + url="https://alias.example.com/goals", + login_complete_url="https://app.example.com/", + ) + + assert site.login_target == "https://app.example.com/" + + def test_defaults(self) -> None: + site = SsoSite(url="https://app.example.com/") + + assert site.settle_ms == 0 + assert site.nav_timeout_ms == NAV_TIMEOUT_MS + assert site.extra_auth_domains == () + + +class TestShouldUseHeadedBrowser: + site = SsoSite(url="https://app.example.com/") + + def test_both_force_flags_is_an_error(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="both --headed and --headless"): + should_use_headed_browser( + self.site, + profile_dir=tmp_path, + force_headed=True, + force_headless=True, + ) + + def test_force_headed_skips_the_probe(self, tmp_path: Path) -> None: + with patch("ess_browser.site.is_sso_login_required") as probe: + assert should_use_headed_browser( + self.site, + profile_dir=tmp_path, + force_headed=True, + ) + probe.assert_not_called() + + def test_force_headless_skips_the_probe(self, tmp_path: Path) -> None: + with patch("ess_browser.site.is_sso_login_required") as probe: + assert not should_use_headed_browser( + self.site, + profile_dir=tmp_path, + force_headless=True, + ) + probe.assert_not_called() + + def test_falls_back_to_the_probe(self, tmp_path: Path) -> None: + with patch( + "ess_browser.site.is_sso_login_required", return_value=True + ) as probe: + assert should_use_headed_browser(self.site, profile_dir=tmp_path) + probe.assert_called_once() + + def test_probe_inherits_site_settle_and_domains(self, tmp_path: Path) -> None: + """The probe must agree with the real run, or it picks the wrong mode.""" + site = SsoSite( + url="https://app.example.com/", + settle_ms=SETTLE_MS, + extra_auth_domains=(TENANT_IDP_DOMAIN,), + ) + + with patch( + "ess_browser.site.is_sso_login_required", return_value=False + ) as probe: + should_use_headed_browser(site, profile_dir=tmp_path) + + _, kwargs = probe.call_args + assert kwargs["settle_timeout_ms"] == SETTLE_MS + assert kwargs["extra_auth_domains"] == (TENANT_IDP_DOMAIN,) + + def test_zero_settle_uses_the_probe_default(self, tmp_path: Path) -> None: + with patch( + "ess_browser.site.is_sso_login_required", return_value=False + ) as probe: + should_use_headed_browser(self.site, profile_dir=tmp_path) + + _, kwargs = probe.call_args + assert kwargs["settle_timeout_ms"] > 0 + + +class TestProfileDirHelpers: + def test_default_profile_dir_is_per_tool(self) -> None: + result = default_profile_dir("my-tool") + + assert result.parent.name == ".my-tool" + assert result.name == "browser-profile" + + def test_display_form_does_not_leak_the_home_path(self) -> None: + assert profile_dir_display("my-tool") == "~/.my-tool/browser-profile" + + def test_display_form_matches_the_real_path(self) -> None: + displayed = profile_dir_display("my-tool").replace("~", str(Path.home()), 1) + + assert Path(displayed) == default_profile_dir("my-tool") diff --git a/packages/python/ess-dirs/LICENSE b/packages/python/ess-dirs/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/packages/python/ess-dirs/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/ess-dirs/pyproject.toml b/packages/python/ess-dirs/pyproject.toml index 46009752..29980e2b 100644 --- a/packages/python/ess-dirs/pyproject.toml +++ b/packages/python/ess-dirs/pyproject.toml @@ -2,11 +2,12 @@ name = "ess-dirs" version = "0.1.0" description = "Secure filesystem helpers (write files with restrictive permissions)." +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/packages/python/ess-langsmith-client/LICENSE b/packages/python/ess-langsmith-client/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/packages/python/ess-langsmith-client/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/ess-langsmith-client/docs/README.md b/packages/python/ess-langsmith-client/docs/README.md index f46c8cbb..53366c37 100644 --- a/packages/python/ess-langsmith-client/docs/README.md +++ b/packages/python/ess-langsmith-client/docs/README.md @@ -6,6 +6,6 @@ Detailed guides for each `langsmith-client` subcommand. Start with the [package - [Workspaces](workspaces.md) — `workspaces` list/get; find the workspace IDs other commands need. - [Deploying agents](deploying-agents.md) — `deploy docker` and `deploy github`: canonical naming, idempotent upsert, git-SHA rescue, secrets, scale. - [Building images](building-images.md) — `build`: LangGraph image build, tagging, `--push`. -- [Listeners](listeners.md) — `listeners list` for hybrid (self-hosted) deployments. +- [Listeners](listeners.md) — `listeners list` and `listeners delete` for hybrid (self-hosted) deployments. - [Projects](projects.md) — `projects` (tracing) vs `control-plane` (control-plane records). - [Testing agents](testing-agents.md) — `test-deployed` resolution flags. diff --git a/packages/python/ess-langsmith-client/docs/api-keys.md b/packages/python/ess-langsmith-client/docs/api-keys.md index a8b06f84..639ae0bb 100644 --- a/packages/python/ess-langsmith-client/docs/api-keys.md +++ b/packages/python/ess-langsmith-client/docs/api-keys.md @@ -34,12 +34,25 @@ The table shows description, short key, age in days, expiry (with an `EXPIRED` m Create a new service API key. ```bash -uv run langsmith-client keys create "my-service-key" --format json +# Create a key after confirming at the prompt +uv run langsmith-client keys create "my-service-key" + +# Show the key that would be created, then stop +uv run langsmith-client keys create "my-service-key" --dry-run + +# Create without a prompt and print JSON, for scripts +uv run langsmith-client keys create "my-service-key" --format json --yes ``` - Argument: `DESCRIPTION` — the human-readable name shown in the LangSmith UI. - The **full key value is displayed once, at creation time only.** Copy it immediately. +| Option | Description | +| --- | --- | +| `--format` | `table` (default) or `json`. | +| `--dry-run` | Show the key that would be created, then exit without creating it. Wins over `--yes`. | +| `--yes`, `-y` | Skip the confirmation prompt. Required with `--format json` or when there is no terminal to prompt on. | + ## `keys delete` Delete one or more keys by exact description. @@ -48,6 +61,9 @@ Delete one or more keys by exact description. # Delete several keys uv run langsmith-client keys delete "key-1" "key-2" "key-3" +# List what would be deleted, then stop +uv run langsmith-client keys delete "key-1" --dry-run + # Skip the confirmation prompt uv run langsmith-client keys delete "key-1" --yes @@ -58,7 +74,8 @@ uv run langsmith-client keys delete "duplicated-name" --all | Option | Description | | --- | --- | | `--all` | Delete every key matching a description, even when one description matches multiple keys. | -| `--yes` | Skip the confirmation prompt. | +| `--dry-run` | Show the keys that would be deleted, then exit without deleting. Wins over `--yes`. | +| `--yes`, `-y` | Skip the confirmation prompt. Required when there is no terminal to prompt on. | ### Duplicate safeguard diff --git a/packages/python/ess-langsmith-client/docs/listeners.md b/packages/python/ess-langsmith-client/docs/listeners.md index 023beaf4..9605b166 100644 --- a/packages/python/ess-langsmith-client/docs/listeners.md +++ b/packages/python/ess-langsmith-client/docs/listeners.md @@ -1,9 +1,10 @@ # Listeners -List LangSmith listeners with `langsmith-client listeners`. Listeners connect the LangSmith control plane to a self-hosted Kubernetes cluster; their ID is required when creating Docker-based deployments against that cluster. +List and delete LangSmith listeners with `langsmith-client listeners`. Listeners connect the LangSmith control plane to a self-hosted Kubernetes cluster; their ID is required when creating Docker-based deployments against that cluster. ```bash uv run langsmith-client listeners list +uv run langsmith-client listeners delete --dry-run ``` Requires `LANGSMITH_API_KEY` and `LANGSMITH_WORKSPACE_ID`. @@ -17,4 +18,17 @@ Requires `LANGSMITH_API_KEY` and `LANGSMITH_WORKSPACE_ID`. | `--region {us,eu}` | Control plane region (default: `us`). | | `--format {table,json}` | Output format (default: `table`). | -The table shows each listener's ID, name, and status. Feed the ID into [`deploy docker create --listener-id `](deploying-agents.md) for hybrid deployments. +The table shows each listener's ID, compute ID (usually the Kubernetes cluster name), and version. Feed the ID into [`deploy docker create --listener-id `](deploying-agents.md) for hybrid deployments. + +## `listeners delete LISTENER_ID...` + +Deletes one or more listeners by ID, for example after the cluster behind a listener has been torn down. It refuses to delete a listener that still has deployments placed on it, checking again after confirmation, and lists those deployments instead; delete or move them first. That check is a client-side snapshot: the control plane delete has no in-use precondition, so a deployment placed after the second check can still be removed with the listener. + +| Option | Description | +| --- | --- | +| `LISTENER_ID...` | One or more listener IDs (required). | +| `--dry-run` | Show the listeners that would be deleted, then exit without deleting. Wins over `--yes`. | +| `--yes`, `-y` | Skip the confirmation prompt. Required when there is no terminal to prompt on. | +| `--api-key`, `--workspace-id`, `--region` | As for `listeners list`. | + +Deleting a listener does not touch the cluster. Uninstall the dataplane Helm release there separately. diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/client.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/client.py index ae7b1994..5695d948 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/client.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/client.py @@ -9,6 +9,7 @@ import os import time +import uuid from http import HTTPStatus from typing import Any @@ -19,6 +20,8 @@ _REQUEST_TIMEOUT = 30 +_PAGE_SIZE = 100 + # Smith app/data API host (tracing sessions, API keys, workspaces). # Distinct from the control-plane host below (deployments and projects). _SMITH_API_URL = "https://api.smith.langchain.com" @@ -40,6 +43,25 @@ _UNHEALTHY_STATUS_MARKERS = ("FAIL", "ERROR", "DELET") +def _require_listener_id(listener_id: str) -> str: + """Return the canonical UUID form of a listener ID. + + The control plane takes the ID as a single path segment. Rejecting + anything else keeps values such as ``../deployments/`` from being + interpolated into the request URL. + + Raises: + ValueError: If listener_id is not a UUID string. + """ + if not isinstance(listener_id, str): + raise ValueError(f"Listener ID must be a UUID, got {listener_id!r}.") + try: + parsed = uuid.UUID(listener_id) + except ValueError as exc: + raise ValueError(f"Listener ID must be a UUID, got {listener_id!r}.") from exc + return str(parsed) + + class ControlPlaneClient: """Client for the LangSmith Control Plane API.""" @@ -109,6 +131,104 @@ def list_listeners(self) -> dict[str, Any]: f"Failed to list listeners: {response.status_code}\n{response.text}" ) + def get_listener(self, listener_id: str) -> dict[str, Any]: + """Get a specific listener by ID. + + Args: + listener_id: Listener UUID. Other values are rejected before any + request is sent. + + Raises: + ValueError: If listener_id is not a UUID. + RuntimeError: If the API returns a non-200 status. + """ + listener_id = _require_listener_id(listener_id) + response = requests.get( + f"{self.base_url}/listeners/{listener_id}", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code == HTTPStatus.OK: + return response.json() + raise RuntimeError( + f"Failed to get listener {listener_id}: " + f"{response.status_code}\n{response.text}" + ) + + def delete_listener(self, listener_id: str) -> bool: + """Delete a listener by ID. + + Args: + listener_id: Listener UUID. Other values are rejected before any + request is sent. + + Returns: + True on success (HTTP 204). + + Raises: + ValueError: If listener_id is not a UUID. + RuntimeError: If the API returns a non-204 status, or the request + fails in transit. A transport failure means the deletion + outcome is unknown and must be checked before retrying. + """ + listener_id = _require_listener_id(listener_id) + try: + response = requests.delete( + f"{self.base_url}/listeners/{listener_id}", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + except requests.RequestException as exc: + raise RuntimeError( + f"Failed to delete listener {listener_id}: {exc}. " + "The deletion outcome is unknown; check whether the listener " + "still exists before retrying, since the server may already " + "have processed the DELETE." + ) from exc + + if response.status_code == HTTPStatus.NO_CONTENT: + return True + raise RuntimeError( + f"Failed to delete listener {listener_id}: " + f"{response.status_code}\n{response.text}" + ) + + def list_all_deployments(self) -> list[dict[str, Any]]: + """List every deployment in the workspace, following pagination. + + Raises: + RuntimeError: If a page is not HTTP 200, or its body has no + ``resources`` list. A missing list is not an empty page, so a + listener delete cannot proceed when the in-use check cannot + see deployments. + """ + deployments: list[dict[str, Any]] = [] + offset = 0 + while True: + response = requests.get( + f"{self.base_url}/deployments", + headers=self.headers, + params={"limit": _PAGE_SIZE, "offset": offset}, + timeout=_REQUEST_TIMEOUT, + ) + if response.status_code != HTTPStatus.OK: + raise RuntimeError( + f"Failed to list deployments: " + f"{response.status_code}\n{response.text}" + ) + payload = response.json() + page = payload.get("resources") if isinstance(payload, dict) else None + if not isinstance(page, list): + raise RuntimeError( + "Failed to list deployments: response has no resources list.\n" + f"{response.text}" + ) + deployments.extend(page) + if len(page) < _PAGE_SIZE: + return deployments + offset += len(page) + def list_deployments(self, name_contains: str | None = None) -> dict[str, Any]: """List all deployments, optionally filtered by name.""" params = {} diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/secrets.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/secrets.py index 552a8d58..b6d34321 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/secrets.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/secrets.py @@ -3,6 +3,15 @@ import os from collections.abc import Iterable +_PLATFORM_MANAGED_SECRET_NAMES = frozenset( + { + "LANGSMITH_CONTROL_PLANE_API_KEY", + "LANGSMITH_ENDPOINT", + "LANGSMITH_PROJECT", + "LANGSMITH_TRACING", + } +) + def get_env_secrets(keys: Iterable[str]) -> list[dict[str, str]]: """Read the named environment variables as deployment secrets. @@ -48,10 +57,23 @@ def merge_secrets( auto_detect_keys: Environment variable names to fall back to. Returns: - The merged list. ``cli_secrets`` win on name collisions. + The merged list. ``cli_secrets`` win on name collisions. Names managed + by the LangSmith control plane are omitted because the deployment API + rejects customer attempts to set them. """ all_secrets = parse_secrets(list(cli_secrets)) for secret in get_env_secrets(auto_detect_keys): if not any(existing["name"] == secret["name"] for existing in all_secrets): all_secrets.append(secret) - return all_secrets + filtered_secrets = [ + secret + for secret in all_secrets + if secret["name"] not in _PLATFORM_MANAGED_SECRET_NAMES + ] + if all_secrets and not filtered_secrets: + names = ", ".join(sorted(secret["name"] for secret in all_secrets)) + raise ValueError( + "All requested deployment secrets are managed by the LangSmith " + f"control plane and cannot be submitted: {names}" + ) + return filtered_secrets diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_cli.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_cli.py index 0e579e76..3b59ce34 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_cli.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_cli.py @@ -32,7 +32,7 @@ def _deployment(name, custom_url): class TestResolveLiveDeploymentRecord: def test_returns_live_deployment(self, monkeypatch): - resource = _deployment("hello-agent-dev", "https://a.example/lgp/x") + resource = _deployment("hello-agent-dev", "https://app.example.com/lgp/x") _stub_resources(monkeypatch, [resource]) client = ControlPlaneClient(**_CREDS) live = resolve_live_deployment_record(client, service="hello-agent", env="dev") @@ -51,12 +51,12 @@ def test_returns_none_when_missing(self, monkeypatch): class TestResolveDeploymentBaseUrl: def test_returns_custom_url_for_canonical_name(self, monkeypatch): - url = "https://a.example/lgp/hello-agent-dev-abc" + url = "https://app.example.com/lgp/hello-agent-dev-abc" _stub_resources(monkeypatch, [_deployment("hello-agent-dev", url)]) assert resolve_deployment_base_url("hello-agent", env="dev", **_CREDS) == url def test_explicit_deployment_overrides_service(self, monkeypatch): - url = "https://a.example/lgp/hello-agent-prod-dev-xyz" + url = "https://app.example.com/lgp/hello-agent-prod-dev-xyz" _stub_resources(monkeypatch, [_deployment("hello-agent-prod-dev", url)]) resolved = resolve_deployment_base_url( "hello-agent", deployment="hello-agent-prod-dev", **_CREDS diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_client.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_client.py index c9d30c50..a0a02887 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_client.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_client.py @@ -1,9 +1,15 @@ -"""Tests for ControlPlaneClient deployment resolution.""" +"""Tests for ControlPlaneClient deployment resolution and listener calls.""" + +from http import HTTPStatus import pytest +import requests +from ess_langsmith_client import client as client_module from ess_langsmith_client.client import ControlPlaneClient +_LISTENER_ID = "11111111-1111-1111-1111-111111111111" + @pytest.fixture def client() -> ControlPlaneClient: @@ -126,3 +132,123 @@ def test_returns_none_when_all_stuck(self, client): [{"name": "hello-agent-dev", "id": "stuck", "status": "FAILED"}], ) assert client.resolve_live_deployment("hello-agent-dev") is None + + +class _FakeResponse: + def __init__(self, status_code: int, payload: dict | None = None): + self.status_code = status_code + self._payload = payload or {} + self.text = "" + + def json(self) -> dict: + return self._payload + + +class TestListAllDeployments: + def test_follows_pagination(self, client, monkeypatch): + page_size = client_module._PAGE_SIZE + pages = [ + [{"id": f"a{i}"} for i in range(page_size)], + [{"id": "last"}], + ] + offsets: list[int] = [] + + def _fake_get(url, headers, params, timeout): + offsets.append(params["offset"]) + return _FakeResponse(HTTPStatus.OK, {"resources": pages[len(offsets) - 1]}) + + monkeypatch.setattr(client_module.requests, "get", _fake_get) + deployments = client.list_all_deployments() + assert len(deployments) == page_size + 1 + assert offsets == [0, page_size] + + def test_raises_on_error(self, client, monkeypatch): + monkeypatch.setattr( + client_module.requests, + "get", + lambda *args, **kwargs: _FakeResponse(HTTPStatus.FORBIDDEN), + ) + with pytest.raises(RuntimeError, match="403"): + client.list_all_deployments() + + def test_empty_resources_is_an_empty_workspace(self, client, monkeypatch): + monkeypatch.setattr( + client_module.requests, + "get", + lambda *args, **kwargs: _FakeResponse(HTTPStatus.OK, {"resources": []}), + ) + assert client.list_all_deployments() == [] + + @pytest.mark.parametrize( + "payload", + [{}, {"resources": {}}, {"resources": None}], + ) + def test_missing_resources_fails_closed(self, client, monkeypatch, payload): + monkeypatch.setattr( + client_module.requests, + "get", + lambda *args, **kwargs: _FakeResponse(HTTPStatus.OK, payload), + ) + with pytest.raises(RuntimeError, match="resources"): + client.list_all_deployments() + + def test_non_object_body_fails_closed(self, client, monkeypatch): + class _ListBody: + status_code = HTTPStatus.OK + text = "[]" + + def json(self) -> list: + return [] + + monkeypatch.setattr( + client_module.requests, + "get", + lambda *args, **kwargs: _ListBody(), + ) + with pytest.raises(RuntimeError, match="resources"): + client.list_all_deployments() + + +class TestDeleteListener: + def test_no_content_is_success(self, client, monkeypatch): + urls: list[str] = [] + + def _fake_delete(url, headers, timeout): + urls.append(url) + return _FakeResponse(HTTPStatus.NO_CONTENT) + + monkeypatch.setattr(client_module.requests, "delete", _fake_delete) + assert client.delete_listener(_LISTENER_ID.upper()) is True + assert urls == [f"{client.base_url}/listeners/{_LISTENER_ID}"] + + def test_not_found_raises(self, client, monkeypatch): + monkeypatch.setattr( + client_module.requests, + "delete", + lambda *args, **kwargs: _FakeResponse(HTTPStatus.NOT_FOUND), + ) + with pytest.raises(RuntimeError, match="404"): + client.delete_listener(_LISTENER_ID) + + def test_rejects_non_uuid_before_request(self, client, monkeypatch): + def _boom(*args, **kwargs): + raise AssertionError("request should not be sent") + + monkeypatch.setattr(client_module.requests, "get", _boom) + monkeypatch.setattr(client_module.requests, "delete", _boom) + traversal = f"../deployments/{_LISTENER_ID}" + fragment = f"{_LISTENER_ID}#fragment" + for listener_id in (traversal, fragment): + with pytest.raises(ValueError, match="UUID"): + client.get_listener(listener_id) + with pytest.raises(ValueError, match="UUID"): + client.delete_listener(listener_id) + + def test_transport_error_outcome_is_unknown(self, client, monkeypatch): + def _fake_delete(url, headers, timeout): + raise requests.Timeout("timed out") + + monkeypatch.setattr(client_module.requests, "delete", _fake_delete) + with pytest.raises(RuntimeError, match="outcome is unknown") as exc_info: + client.delete_listener(_LISTENER_ID) + assert isinstance(exc_info.value.__cause__, requests.Timeout) diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_secrets.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_secrets.py index 6adb0f6c..067597d5 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_secrets.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_secrets.py @@ -46,6 +46,55 @@ def test_merge_secrets_cli_wins_over_environment(monkeypatch): ] +def test_merge_secrets_omits_platform_managed_cli_secret(): + assert merge_secrets( + [ + "LANGSMITH_CONTROL_PLANE_API_KEY=platform-owned", + "LANGSMITH_ENDPOINT=https://langsmith.example.com", + "LANGSMITH_PROJECT=platform-project", + "LANGSMITH_TRACING=true", + "LANGSMITH_API_KEY=deployment-key", + ] + ) == [{"name": "LANGSMITH_API_KEY", "value": "deployment-key"}] + + +def test_merge_secrets_omits_platform_managed_environment_secret(monkeypatch): + monkeypatch.setenv("LANGSMITH_CONTROL_PLANE_API_KEY", "platform-owned") + monkeypatch.setenv("LANGSMITH_API_KEY", "deployment-key") + + assert merge_secrets( + [], + auto_detect_keys=[ + "LANGSMITH_CONTROL_PLANE_API_KEY", + "LANGSMITH_API_KEY", + ], + ) == [{"name": "LANGSMITH_API_KEY", "value": "deployment-key"}] + + +@pytest.mark.parametrize( + "name", + [ + "LANGSMITH_CONTROL_PLANE_API_KEY", + "LANGSMITH_ENDPOINT", + "LANGSMITH_PROJECT", + "LANGSMITH_TRACING", + ], +) +def test_merge_secrets_rejects_platform_managed_only_cli_input(name: str): + with pytest.raises(ValueError, match=name): + merge_secrets([f"{name}=platform-owned"]) + + +def test_merge_secrets_rejects_platform_managed_only_environment_input(monkeypatch): + monkeypatch.setenv("LANGSMITH_CONTROL_PLANE_API_KEY", "platform-owned") + + with pytest.raises(ValueError, match="LANGSMITH_CONTROL_PLANE_API_KEY"): + merge_secrets( + [], + auto_detect_keys=["LANGSMITH_CONTROL_PLANE_API_KEY"], + ) + + @pytest.mark.parametrize("cli_secrets", [(), []]) def test_merge_secrets_accepts_tuple_or_list(cli_secrets): assert merge_secrets(cli_secrets) == [] diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_docker.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_docker.py index 5bfab7dc..8ba2b640 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_docker.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_docker.py @@ -52,12 +52,12 @@ get_git_sha, get_project_info, handle_wait, - merge_secrets, print_deployments, resolve_live_deployment_record, ) from ess_langsmith_client.client import _REQUEST_TIMEOUT from ess_langsmith_client.naming import resolve_deploy_base +from ess_langsmith_client.tools.secret_options import merge_cli_secrets def extract_name_from_image_uri(image_uri: str) -> str: @@ -271,6 +271,7 @@ def create( - creates a git-SHA rescue name (--) only when the canonical name is stuck/orphaned. """ + secret_list = merge_cli_secrets(secrets) client = create_client(DockerDeploymentClient, api_key, workspace_id, region) # Get project info from pyproject.toml @@ -306,8 +307,6 @@ def create( "Provide --image-uri or ensure pyproject.toml exists" ) - secret_list = merge_secrets(secrets) - try: live = client.resolve_live_deployment(base) @@ -395,6 +394,7 @@ def update( wait: bool, ): """Update an existing deployment with a new Docker image.""" + secret_list = merge_cli_secrets(secrets) # Derive image URI from pyproject.toml if not provided if not image_uri: project = get_project_info(start_path=Path(project_dir)) @@ -408,8 +408,6 @@ def update( ) client = create_client(DockerDeploymentClient, api_key, workspace_id, region) - secret_list = merge_secrets(secrets) - click.echo(f"Updating deployment: {deployment_id}") click.echo(f" New image: {image_uri}") if secret_list: diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_github.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_github.py index 70047558..303594d9 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_github.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_github.py @@ -49,10 +49,10 @@ get_git_sha, get_project_info, handle_wait, - merge_secrets, print_deployments, ) from ess_langsmith_client.client import _REQUEST_TIMEOUT +from ess_langsmith_client.tools.secret_options import merge_cli_secrets class GitHubDeploymentClient(ControlPlaneClient): @@ -266,6 +266,7 @@ def create( - creates a git-SHA rescue name (--) only when the canonical name is stuck/orphaned. """ + secret_list = merge_cli_secrets(secrets) if not integration_id: raise click.ClickException( "GitHub integration ID is required.\n" @@ -301,7 +302,7 @@ def create( branch=branch, config_path=config_path, build_on_push=auto_build, - secrets=merge_secrets(secrets), + secrets=secret_list, min_scale=min_scale, max_scale=max_scale, cpu=cpu, @@ -340,7 +341,7 @@ def create( deployment_type=deployment_type, build_on_push=auto_build, shareable=shareable, - secrets=merge_secrets(secrets), + secrets=secret_list, min_scale=min_scale, max_scale=max_scale, cpu=cpu, diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py index 186df0d8..9117f933 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py @@ -10,12 +10,15 @@ langsmith-client keys list --expired langsmith-client keys list --older-than 90 langsmith-client keys create "LangSmith Deployment: my-app" + langsmith-client keys create "LangSmith Deployment: my-app" --dry-run langsmith-client keys delete "my-old-key" "another-old-key" langsmith-client keys delete "duplicated-name" --all + langsmith-client keys delete "my-old-key" --dry-run """ import json import os +import sys from datetime import datetime, timezone from http import HTTPStatus from typing import Any @@ -218,6 +221,28 @@ def _format_duplicate_error(duplicates: dict[str, list[dict[str, Any]]]) -> str: return "\n".join(error_lines) +def _is_interactive() -> bool: + return sys.stdin.isatty() + + +def _confirm_or_abort( + question: str, *, action: str, skip: bool, as_json: bool = False +) -> None: + """Require explicit consent before writing keys.""" + if skip: + return + # JSON output is for scripts, and without a terminal there is no one to + # answer the prompt, so make the caller opt in rather than reading an + # empty stdin as "no". + if as_json: + raise click.UsageError( + f"--format json requires --yes, which confirms the {action}." + ) + if not _is_interactive(): + raise click.UsageError(f"Not running interactively; pass --yes to {action}.") + click.confirm(question, abort=True, err=True) + + def _common_options(func): func = click.option( "--api-key", @@ -295,25 +320,64 @@ def list_keys( default="table", help="Output format (default: table)", ) -def create( +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="Show the key that would be created without creating it.", +) +@click.option( + "--yes", "-y", is_flag=True, default=False, help="Skip the confirmation prompt." +) +def create( # noqa: PLR0913 -- Click maps one parameter per option api_key: str | None, workspace_id: str | None, description: str, output_format: str, + dry_run: bool, + yes: bool, ): """Create a new service API key. The full key value is shown only once -- copy it immediately. + The command asks for confirmation before creating the key. Pass --yes to + skip the prompt, which is required with --format json or when not running + in a terminal. Pass --dry-run to show the key and stop; it wins over --yes. + \b Examples: langsmith-client keys create "LangSmith Deployment: hello-world-graph" - langsmith-client keys create "my-service-key" --format json + langsmith-client keys create "my-service-key" --dry-run + langsmith-client keys create "my-service-key" --format json --yes """ try: client = APIKeyClient(api_key, workspace_id) + except ValueError as e: + raise click.ClickException(str(e)) from e + + target = f"'{description}'" + if client.headers.get("X-Tenant-Id"): + target += f" in workspace {client.headers['X-Tenant-Id']}" + + if dry_run: + if output_format == "json": + plan = {"dry_run": True, "description": description} + click.echo(json.dumps(plan, indent=2)) + else: + click.echo(f"Dry run: would create key {target}; nothing changed.") + return + + _confirm_or_abort( + f"Create key {target}?", + action="create", + skip=yes, + as_json=output_format == "json", + ) + + try: result = client.create_key(description) - except (ValueError, RuntimeError) as e: + except RuntimeError as e: raise click.ClickException(str(e)) from e if output_format == "json": @@ -349,12 +413,21 @@ def create( "matches more than one key (default: error on duplicates)." ), ) -@click.option("--yes", is_flag=True, help="Skip confirmation prompt") -def delete( +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="Show the keys that would be deleted without deleting them.", +) +@click.option( + "--yes", "-y", is_flag=True, default=False, help="Skip the confirmation prompt." +) +def delete( # noqa: PLR0913 -- Click maps one parameter per option api_key: str | None, workspace_id: str | None, descriptions: tuple[str, ...], delete_all: bool, + dry_run: bool, yes: bool, ): """Delete API keys by description (name). @@ -366,11 +439,16 @@ def delete( refuses to delete and exits with an error. Pass --all to explicitly delete every matching key. + The command lists the keys it will delete and asks for confirmation. + Pass --yes to skip the prompt, which is required when not running in a + terminal. Pass --dry-run to list the keys and stop; it wins over --yes. + \b Examples: langsmith-client keys delete "my-old-key" langsmith-client keys delete "key-1" "key-2" "key-3" langsmith-client keys delete "key-1" --yes + langsmith-client keys delete "key-1" --dry-run langsmith-client keys delete "duplicated-name" --all """ try: @@ -399,8 +477,11 @@ def delete( ) click.echo() - if not yes: - click.confirm("Delete these keys?", abort=True) + if dry_run: + click.echo(f"Dry run: {len(targets)} key(s) would be deleted; nothing changed.") + return + + _confirm_or_abort(f"Delete {len(targets)} key(s)?", action="delete", skip=yes) deleted = 0 failed = 0 diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/list_listeners.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/list_listeners.py deleted file mode 100644 index 6226943a..00000000 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/list_listeners.py +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env python3 -""" -List LangSmith listeners (hybrid deployment agents). - -Listeners connect the LangSmith control plane to a self-hosted Kubernetes -cluster. The listener ID is required when creating Docker-based deployments -against a self-hosted cluster. - -PREREQUISITES: -- LANGSMITH_API_KEY: Your LangSmith API key -- LANGSMITH_WORKSPACE_ID: Your LangSmith workspace ID - -USAGE: - langsmith-client listeners list - langsmith-client listeners list --format json - -CONTROL PLANE API REFERENCE: - https://docs.langchain.com/langsmith/api-ref-control-plane -""" - -import json -from typing import Any - -import click - -from ess_langsmith_client import ( - ControlPlaneClient, - common_options, - create_client, -) - -# ============================================================================= -# Output Formatters -# ============================================================================= - - -def print_table(listeners: list[dict[str, Any]]) -> None: - """Print listeners as a formatted table.""" - if not listeners: - click.echo("No listeners found.") - return - - id_width = max( - (len(str(listener.get("id", ""))) for listener in listeners), - default=36, - ) - id_width = max(id_width, len("ID")) - name_width = max( - (len(str(listener.get("name", ""))) for listener in listeners), - default=20, - ) - name_width = max(name_width, len("Name")) - - click.echo(f"\nFound {len(listeners)} listener(s):\n") - header = f"{'ID':<{id_width}} {'Name':<{name_width}} Status" - click.echo(header) - click.echo("-" * len(header)) - - for listener in listeners: - click.echo( - f"{str(listener.get('id', '')):<{id_width}} " - f"{str(listener.get('name', '')):<{name_width}} " - f"{listener.get('status', 'N/A')}" - ) - - click.echo() - - -def print_json(listeners: list[dict[str, Any]]) -> None: - """Print listeners as JSON.""" - click.echo(json.dumps(listeners, indent=2, default=str)) - - -# ============================================================================= -# CLI Commands -# ============================================================================= - - -@click.group() -@click.version_option(version="1.0.0") -def cli(): - """List LangSmith listeners for hybrid (self-hosted) deployments. - - \b - PREREQUISITES: - - LANGSMITH_API_KEY: Your LangSmith API key - - LANGSMITH_WORKSPACE_ID: Your workspace ID (from LangSmith settings) - - \b - The listener ID displayed here is used as --listener-id when creating - Docker-based deployments against a self-hosted Kubernetes cluster. - """ - pass - - -@cli.command("list") -@common_options -@click.option( - "--format", - "output_format", - type=click.Choice(["table", "json"]), - default="table", - help="Output format (default: table)", -) -def list_listeners( - region: str, - api_key: str | None, - workspace_id: str | None, - output_format: str, -): - """List listeners for hybrid deployments.""" - client: ControlPlaneClient = create_client( - ControlPlaneClient, api_key, workspace_id, region - ) - - click.echo("Fetching listeners...") - - try: - result = client.list_listeners() - listeners = ( - result.get("resources", result) if isinstance(result, dict) else result - ) - - if output_format == "json": - print_json(listeners) - else: - print_table(listeners) - - except RuntimeError as e: - raise click.ClickException(str(e)) from e - - -if __name__ == "__main__": - cli() diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/listeners.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/listeners.py new file mode 100644 index 00000000..378d8b2b --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/listeners.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +""" +List and delete LangSmith listeners (hybrid deployment agents). + +Listeners connect the LangSmith control plane to a self-hosted Kubernetes +cluster. The listener ID is required when creating Docker-based deployments +against a self-hosted cluster. + +PREREQUISITES: +- LANGSMITH_API_KEY: Your LangSmith API key +- LANGSMITH_WORKSPACE_ID: Your LangSmith workspace ID + +USAGE: + langsmith-client listeners list + langsmith-client listeners list --format json + langsmith-client listeners delete --dry-run + langsmith-client listeners delete + +CONTROL PLANE API REFERENCE: + https://docs.langchain.com/langsmith/api-ref-control-plane +""" + +import json +import sys +from typing import Any + +import click + +from ess_langsmith_client import ( + ControlPlaneClient, + common_options, + create_client, +) + +# ============================================================================= +# Output Formatters +# ============================================================================= + + +def print_table(listeners: list[dict[str, Any]]) -> None: + """Print listeners as a formatted table.""" + if not listeners: + click.echo("No listeners found.") + return + + id_width = max( + (len(str(listener.get("id", ""))) for listener in listeners), + default=36, + ) + id_width = max(id_width, len("ID")) + compute_width = max( + (len(str(listener.get("compute_id", ""))) for listener in listeners), + default=20, + ) + compute_width = max(compute_width, len("Compute ID")) + + click.echo(f"\nFound {len(listeners)} listener(s):\n") + header = f"{'ID':<{id_width}} {'Compute ID':<{compute_width}} Version" + click.echo(header) + click.echo("-" * len(header)) + + for listener in listeners: + click.echo( + f"{str(listener.get('id', '')):<{id_width}} " + f"{str(listener.get('compute_id', '')):<{compute_width}} " + f"{listener.get('version') or 'N/A'}" + ) + + click.echo() + + +def print_json(listeners: list[dict[str, Any]]) -> None: + """Print listeners as JSON.""" + click.echo(json.dumps(listeners, indent=2, default=str)) + + +def _describe_listener(listener: dict[str, Any]) -> str: + namespaces = (listener.get("compute_config") or {}).get("k8s_namespaces") or [] + return ( + f"{listener.get('id', '')} " + f"compute_id={listener.get('compute_id') or '?'} " + f"namespaces={','.join(namespaces) or '?'} " + f"created={str(listener.get('created_at') or '?')[:10]}" + ) + + +def _format_in_use_error(placements: dict[str, list[dict[str, Any]]]) -> str: + """Build the error listing deployments still placed on target listeners.""" + lines = ["Refusing to delete: these listeners still have deployments."] + for listener_id, deployments in sorted(placements.items()): + lines.append(f" {listener_id} serves {len(deployments)} deployment(s):") + lines.extend( + f" {deployment.get('name', '?')} id={deployment.get('id', '?')}" + for deployment in deployments + ) + lines.append("Delete or move those deployments first.") + return "\n".join(lines) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def _deployments_by_listener( + deployments: list[dict[str, Any]], listener_ids: tuple[str, ...] +) -> dict[str, list[dict[str, Any]]]: + """Map each target listener ID to the deployments placed on it.""" + placements: dict[str, list[dict[str, Any]]] = {} + for deployment in deployments: + listener_id = (deployment.get("source_config") or {}).get("listener_id") + if listener_id in listener_ids: + placements.setdefault(listener_id, []).append(deployment) + return placements + + +def _resolve_targets( + client: ControlPlaneClient, listener_ids: tuple[str, ...] +) -> list[dict[str, Any]]: + """Look up each listener and dedupe by the ID the API returns. + + Placement checks and deletes must use that ID. An argument the server + resolves to a different ID (for example a non-canonical UUID spelling) + would otherwise miss deployments placed on the real listener. + """ + targets: list[dict[str, Any]] = [] + seen: set[str] = set() + for listener_id in dict.fromkeys(listener_ids): + listener = client.get_listener(listener_id) + resolved_id = listener.get("id") + if not isinstance(resolved_id, str) or not resolved_id: + raise RuntimeError( + f"Listener lookup for {listener_id} did not return an id." + ) + if resolved_id in seen: + continue + seen.add(resolved_id) + targets.append(listener) + return targets + + +def _refuse_in_use(client: ControlPlaneClient, listener_ids: tuple[str, ...]) -> None: + """Raise when any target listener still has deployments placed on it.""" + try: + placements = _deployments_by_listener( + client.list_all_deployments(), listener_ids + ) + except RuntimeError as exc: + raise click.ClickException(str(exc)) from exc + if placements: + raise click.ClickException(_format_in_use_error(placements)) + + +def _is_interactive() -> bool: + return sys.stdin.isatty() + + +def _confirm_or_abort(count: int, workspace_id: str | None, *, skip: bool) -> None: + """Require explicit consent before deleting listeners.""" + if skip: + return + if not _is_interactive(): + raise click.UsageError( + "No terminal to confirm the delete; pass --yes to confirm it." + ) + click.confirm( + f"Delete {count} listener(s) from workspace {workspace_id or '(default)'}?", + abort=True, + err=True, + ) + + +# ============================================================================= +# CLI Commands +# ============================================================================= + + +@click.group() +@click.version_option(version="1.0.0") +def cli(): + """List and delete LangSmith listeners for hybrid (self-hosted) deployments. + + \b + PREREQUISITES: + - LANGSMITH_API_KEY: Your LangSmith API key + - LANGSMITH_WORKSPACE_ID: Your workspace ID (from LangSmith settings) + + \b + The listener ID displayed here is used as --listener-id when creating + Docker-based deployments against a self-hosted Kubernetes cluster. + """ + pass + + +@cli.command("list") +@common_options +@click.option( + "--format", + "output_format", + type=click.Choice(["table", "json"]), + default="table", + help="Output format (default: table)", +) +def list_listeners( + region: str, + api_key: str | None, + workspace_id: str | None, + output_format: str, +): + """List listeners for hybrid deployments.""" + client: ControlPlaneClient = create_client( + ControlPlaneClient, api_key, workspace_id, region + ) + + click.echo("Fetching listeners...") + + try: + result = client.list_listeners() + listeners = ( + result.get("resources", result) if isinstance(result, dict) else result + ) + + if output_format == "json": + print_json(listeners) + else: + print_table(listeners) + + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + +@cli.command("delete") +@common_options +@click.argument("listener_ids", nargs=-1, required=True) +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="Show the listeners that would be deleted without deleting them.", +) +@click.option( + "--yes", "-y", is_flag=True, default=False, help="Skip the confirmation prompt." +) +def delete_listeners( # noqa: PLR0913 # one param per CLI option + region: str, + api_key: str | None, + workspace_id: str | None, + listener_ids: tuple[str, ...], + dry_run: bool, + yes: bool, +): + """Delete listeners by ID. + + Refuses to delete a listener that still has deployments placed on it, so + a live cluster cannot be detached by mistake. That check runs before the + confirmation prompt and again immediately after it. The control plane + DELETE has no in-use precondition, so a deployment placed after the + second check can still be removed with the listener. Delete or move + deployments first. + + \b + Examples: + langsmith-client listeners delete --dry-run + langsmith-client listeners delete + langsmith-client listeners delete --yes + """ + client: ControlPlaneClient = create_client( + ControlPlaneClient, api_key, workspace_id, region + ) + + try: + targets = _resolve_targets(client, listener_ids) + except (RuntimeError, ValueError) as e: + raise click.ClickException(str(e)) from e + + resolved_ids = tuple(str(listener["id"]) for listener in targets) + _refuse_in_use(client, resolved_ids) + + workspace_label = client.workspace_id or "(default)" + click.echo( + f"\nListeners to delete ({len(targets)}) in workspace {workspace_label}:\n" + ) + for listener in targets: + click.echo(f" {_describe_listener(listener)}") + click.echo() + + if dry_run: + click.echo("Dry run: no listeners deleted.") + return + + _confirm_or_abort(len(targets), client.workspace_id, skip=yes) + _refuse_in_use(client, resolved_ids) + + deleted = 0 + failed = 0 + for listener_id in resolved_ids: + try: + client.delete_listener(listener_id) + click.echo(click.style(f" Deleted: {listener_id}", fg="green")) + deleted += 1 + except RuntimeError as e: + click.echo(click.style(f" Failed: {listener_id} -- {e}", fg="red")) + failed += 1 + + click.echo() + if deleted: + click.echo(click.style(f"{deleted} listener(s) deleted.", fg="green")) + if failed: + raise click.ClickException(f"{failed} listener(s) failed to delete.") + + +if __name__ == "__main__": + cli() diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/main.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/main.py index 0bf14682..5bdac9b4 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/main.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/main.py @@ -26,8 +26,8 @@ deploy_docker, deploy_github, keys, - list_listeners, list_workspaces, + listeners, projects, ) @@ -51,7 +51,7 @@ def deploy() -> None: cli.add_command(keys.cli, name="keys") cli.add_command(list_workspaces.cli, name="workspaces") -cli.add_command(list_listeners.cli, name="listeners") +cli.add_command(listeners.cli, name="listeners") cli.add_command(projects.cli, name="projects") cli.add_command(control_plane.cli, name="control-plane") cli.add_command(build.build, name="build") diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/secret_options.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/secret_options.py new file mode 100644 index 00000000..f275618d --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/secret_options.py @@ -0,0 +1,13 @@ +"""Click-facing validation for deployment secret options.""" + +import click + +from ess_langsmith_client.secrets import merge_secrets + + +def merge_cli_secrets(secrets: tuple[str, ...]) -> list[dict[str, str]]: + """Merge CLI secrets and present validation failures as usage errors.""" + try: + return merge_secrets(secrets) + except ValueError as exc: + raise click.UsageError(str(exc)) from exc diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_deploy_docker.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_deploy_docker.py new file mode 100644 index 00000000..76afd019 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_deploy_docker.py @@ -0,0 +1,41 @@ +"""Tests for Docker deployment CLI validation.""" + +import pytest +from click import UsageError +from click.testing import CliRunner + +from ess_langsmith_client.tools.deploy_docker import cli + + +@pytest.mark.parametrize( + "args", + [ + [ + "create", + "--deployment", + "agent-test", + "--image-uri", + "registry.example.com/agent:test", + ], + [ + "update", + "--deployment-id", + "deployment-id", + "--image-uri", + "registry.example.com/agent:test", + ], + ], +) +def test_reserved_only_secret_is_an_operator_facing_error(args: list[str]) -> None: + result = CliRunner().invoke( + cli, + [ + *args, + "--secret", + "LANGSMITH_CONTROL_PLANE_API_KEY=platform-owned", + ], + ) + + assert result.exit_code == UsageError.exit_code + assert "Error: All requested deployment secrets are managed" in result.output + assert "LANGSMITH_CONTROL_PLANE_API_KEY" in result.output diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_deploy_github.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_deploy_github.py new file mode 100644 index 00000000..f03e9456 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_deploy_github.py @@ -0,0 +1,23 @@ +"""Tests for GitHub deployment CLI validation.""" + +from click import UsageError +from click.testing import CliRunner + +from ess_langsmith_client.tools.deploy_github import cli + + +def test_reserved_only_secret_is_an_operator_facing_error() -> None: + result = CliRunner().invoke( + cli, + [ + "create", + "--repo-url", + "https://git.example.com/owner/repository", + "--secret", + "LANGSMITH_CONTROL_PLANE_API_KEY=platform-owned", + ], + ) + + assert result.exit_code == UsageError.exit_code + assert "Error: All requested deployment secrets are managed" in result.output + assert "LANGSMITH_CONTROL_PLANE_API_KEY" in result.output diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_keys.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_keys.py index 58458787..c8866abf 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_keys.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_keys.py @@ -201,3 +201,149 @@ def test_unique_deletes_single(self, monkeypatch): result = self._run(monkeypatch, ["unique-key", "--yes"], deleted_ids) assert result.exit_code == 0 assert deleted_ids == ["id-3"] + + def test_dry_run_deletes_nothing(self, monkeypatch): + deleted_ids: list[str] = [] + result = self._run(monkeypatch, ["unique-key", "--dry-run"], deleted_ids) + assert result.exit_code == 0 + assert "unique-key" in result.output + assert "nothing changed" in result.output + assert deleted_ids == [] + + def test_dry_run_wins_over_yes(self, monkeypatch): + deleted_ids: list[str] = [] + result = self._run( + monkeypatch, ["dup-key", "--all", "--dry-run", "--yes"], deleted_ids + ) + assert result.exit_code == 0 + assert deleted_ids == [] + + def test_yes_skips_the_prompt(self, monkeypatch): + monkeypatch.setattr(keys, "_is_interactive", lambda: True) + deleted_ids: list[str] = [] + result = self._run(monkeypatch, ["unique-key", "--yes"], deleted_ids) + assert result.exit_code == 0 + assert "Delete 1 key(s)?" not in result.output + assert deleted_ids == ["id-3"] + + def test_prompt_goes_to_stderr(self, monkeypatch): + monkeypatch.setattr(keys, "_is_interactive", lambda: True) + monkeypatch.setattr( + keys.APIKeyClient, "list_keys", lambda self: _keys_with_duplicate() + ) + deleted_ids: list[str] = [] + monkeypatch.setattr( + keys.APIKeyClient, + "delete_key", + lambda self, key_id: deleted_ids.append(key_id), + ) + + result = CliRunner().invoke( + cli, ["delete", "--api-key", "test-key", "unique-key"], input="y\n" + ) + + assert result.exit_code == 0 + assert "Delete 1 key(s)?" in result.stderr + assert "Delete 1 key(s)?" not in result.stdout + assert deleted_ids == ["id-3"] + + def test_declining_the_prompt_deletes_nothing(self, monkeypatch): + monkeypatch.setattr(keys, "_is_interactive", lambda: True) + deleted_ids: list[str] = [] + monkeypatch.setattr( + keys.APIKeyClient, "list_keys", lambda self: _keys_with_duplicate() + ) + monkeypatch.setattr( + keys.APIKeyClient, + "delete_key", + lambda self, key_id: deleted_ids.append(key_id), + ) + + result = CliRunner().invoke( + cli, ["delete", "--api-key", "test-key", "unique-key"], input="n\n" + ) + + assert result.exit_code != 0 + assert deleted_ids == [] + + def test_non_interactive_without_yes_refuses(self, monkeypatch): + deleted_ids: list[str] = [] + result = self._run(monkeypatch, ["unique-key"], deleted_ids) + assert result.exit_code != 0 + assert "--yes" in result.output + assert deleted_ids == [] + + +class TestCreate: + def _run(self, monkeypatch, args, created, user_input=None): + def _record_create(self, description): + created.append(description) + return {"id": "id-new", "description": description, "key": "fake-value"} + + monkeypatch.setattr(keys.APIKeyClient, "create_key", _record_create) + return CliRunner().invoke( + cli, + ["create", "--api-key", "test-key", "new-key", *args], + input=user_input, + ) + + def test_dry_run_creates_nothing(self, monkeypatch): + created: list[str] = [] + result = self._run( + monkeypatch, ["--dry-run", "--workspace-id", "ws-1"], created + ) + assert result.exit_code == 0 + assert "would create key 'new-key' in workspace ws-1" in result.output + assert created == [] + + def test_dry_run_wins_over_yes(self, monkeypatch): + created: list[str] = [] + result = self._run(monkeypatch, ["--dry-run", "--yes"], created) + assert result.exit_code == 0 + assert created == [] + + def test_dry_run_json_needs_no_yes(self, monkeypatch): + created: list[str] = [] + result = self._run(monkeypatch, ["--dry-run", "--format", "json"], created) + assert result.exit_code == 0 + assert json.loads(result.output) == {"dry_run": True, "description": "new-key"} + assert created == [] + + def test_yes_skips_the_prompt(self, monkeypatch): + monkeypatch.setattr(keys, "_is_interactive", lambda: True) + created: list[str] = [] + result = self._run(monkeypatch, ["--yes"], created) + assert result.exit_code == 0 + assert "Create key" not in result.output + assert created == ["new-key"] + + def test_prompt_goes_to_stderr(self, monkeypatch): + monkeypatch.setattr(keys, "_is_interactive", lambda: True) + created: list[str] = [] + result = self._run(monkeypatch, [], created, user_input="y\n") + assert result.exit_code == 0 + assert "Create key 'new-key'?" in result.stderr + assert "Create key 'new-key'?" not in result.stdout + assert created == ["new-key"] + + def test_declining_the_prompt_creates_nothing(self, monkeypatch): + monkeypatch.setattr(keys, "_is_interactive", lambda: True) + created: list[str] = [] + result = self._run(monkeypatch, [], created, user_input="n\n") + assert result.exit_code != 0 + assert created == [] + + def test_non_interactive_without_yes_refuses(self, monkeypatch): + created: list[str] = [] + result = self._run(monkeypatch, [], created) + assert result.exit_code != 0 + assert "pass --yes to create" in result.output + assert created == [] + + def test_json_without_yes_refuses(self, monkeypatch): + monkeypatch.setattr(keys, "_is_interactive", lambda: True) + created: list[str] = [] + result = self._run(monkeypatch, ["--format", "json"], created) + assert result.exit_code != 0 + assert "--format json requires --yes" in result.output + assert created == [] diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_listeners.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_listeners.py new file mode 100644 index 00000000..f5d92e71 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_listeners.py @@ -0,0 +1,178 @@ +"""Tests for langsmith-client listeners list and delete.""" + +from typing import Any + +import pytest +from click.testing import CliRunner + +from ess_langsmith_client.tools import listeners +from ess_langsmith_client.tools.listeners import cli + +_IDLE_ID = "11111111-1111-1111-1111-111111111111" +_BUSY_ID = "22222222-2222-2222-2222-222222222222" + + +class _FakeClient: + """Stands in for ControlPlaneClient; records delete calls.""" + + workspace_id = "test-ws" + + def __init__(self) -> None: + self.deleted: list[str] = [] + + def list_listeners(self) -> dict[str, Any]: + return {"resources": [self.get_listener(_IDLE_ID)]} + + def get_listener(self, listener_id: str) -> dict[str, Any]: + return { + "id": listener_id, + "compute_id": f"cluster-{listener_id[:4]}", + "compute_config": {"k8s_namespaces": ["default"]}, + "created_at": "2026-04-06T00:00:00Z", + "version": "0.13.9", + } + + def list_all_deployments(self) -> list[dict[str, Any]]: + return [ + { + "id": "dep-1", + "name": "live-agent", + "source_config": {"listener_id": _BUSY_ID}, + }, + ] + + def delete_listener(self, listener_id: str) -> bool: + self.deleted.append(listener_id) + return True + + +@pytest.fixture +def fake_client(monkeypatch) -> _FakeClient: + client = _FakeClient() + monkeypatch.setattr(listeners, "create_client", lambda *args, **kwargs: client) + return client + + +def _invoke(args: list[str], stdin: str | None = None): + return CliRunner().invoke(cli, [*args, "--api-key", "test-key"], input=stdin) + + +class TestList: + def test_table_shows_compute_id_and_version(self, fake_client): + result = _invoke(["list"]) + assert result.exit_code == 0 + assert "cluster-1111" in result.output + assert "0.13.9" in result.output + + +class TestDelete: + def test_dry_run_deletes_nothing(self, fake_client): + result = _invoke(["delete", _IDLE_ID, "--dry-run"]) + assert result.exit_code == 0 + assert _IDLE_ID in result.output + assert "Dry run" in result.output + assert fake_client.deleted == [] + + def test_dry_run_wins_over_yes(self, fake_client): + result = _invoke(["delete", _IDLE_ID, "--dry-run", "--yes"]) + assert result.exit_code == 0 + assert fake_client.deleted == [] + + def test_yes_skips_prompt(self, fake_client): + result = _invoke(["delete", _IDLE_ID, "--yes"]) + assert result.exit_code == 0 + assert fake_client.deleted == [_IDLE_ID] + + def test_duplicate_ids_deleted_once(self, fake_client): + result = _invoke(["delete", _IDLE_ID, _IDLE_ID, "--yes"]) + assert result.exit_code == 0 + assert fake_client.deleted == [_IDLE_ID] + + def test_deletes_id_returned_by_lookup(self, fake_client): + alias = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + original = fake_client.get_listener + + def _get(listener_id: str) -> dict[str, Any]: + listener = original(listener_id) + listener["id"] = _IDLE_ID + return listener + + fake_client.get_listener = _get + result = _invoke(["delete", alias, _IDLE_ID, "--yes"]) + assert result.exit_code == 0 + assert fake_client.deleted == [_IDLE_ID] + + def test_in_use_check_uses_resolved_id(self, fake_client): + alias = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + original = fake_client.get_listener + + def _get(listener_id: str) -> dict[str, Any]: + listener = original(listener_id) + if listener_id == alias: + listener["id"] = _BUSY_ID + return listener + + fake_client.get_listener = _get + result = _invoke(["delete", alias, "--yes"]) + assert result.exit_code != 0 + assert "live-agent" in result.output + assert _BUSY_ID in result.output + assert fake_client.deleted == [] + + def test_invalid_id_is_a_click_error(self, fake_client): + def _get(listener_id: str) -> dict[str, Any]: + raise ValueError(f"Listener ID must be a UUID, got {listener_id!r}.") + + fake_client.get_listener = _get + result = _invoke(["delete", "../deployments/not-a-uuid", "--yes"]) + assert result.exit_code != 0 + assert "UUID" in result.output + assert "Traceback" not in result.output + assert fake_client.deleted == [] + + def test_recheck_after_confirm_blocks_new_placement(self, fake_client, monkeypatch): + monkeypatch.setattr(listeners, "_is_interactive", lambda: True) + phases: list[str] = [] + + def _list() -> list[dict[str, Any]]: + phases.append("before" if not phases else "after") + if phases == ["before"]: + return [] + return [ + { + "id": "dep-new", + "name": "moved-on", + "source_config": {"listener_id": _IDLE_ID}, + } + ] + + fake_client.list_all_deployments = _list + result = _invoke(["delete", _IDLE_ID], stdin="y\n") + assert result.exit_code != 0 + assert "moved-on" in result.output + assert fake_client.deleted == [] + assert phases == ["before", "after"] + + def test_refuses_listener_with_deployments(self, fake_client): + result = _invoke(["delete", _IDLE_ID, _BUSY_ID, "--yes"]) + assert result.exit_code != 0 + assert "live-agent" in result.output + assert fake_client.deleted == [] + + def test_non_interactive_without_yes_fails(self, fake_client): + result = _invoke(["delete", _IDLE_ID]) + assert result.exit_code != 0 + assert "--yes" in result.output + assert fake_client.deleted == [] + + def test_interactive_decline_aborts(self, fake_client, monkeypatch): + monkeypatch.setattr(listeners, "_is_interactive", lambda: True) + result = _invoke(["delete", _IDLE_ID], stdin="n\n") + assert result.exit_code != 0 + assert fake_client.deleted == [] + + def test_interactive_accept_deletes(self, fake_client, monkeypatch): + monkeypatch.setattr(listeners, "_is_interactive", lambda: True) + result = _invoke(["delete", _IDLE_ID], stdin="y\n") + assert result.exit_code == 0 + assert fake_client.deleted == [_IDLE_ID] diff --git a/packages/python/ess-load-test/LICENSE b/packages/python/ess-load-test/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/packages/python/ess-load-test/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/ess-load-test/Pulumi.example.yaml b/packages/python/ess-load-test/Pulumi.example.yaml new file mode 100644 index 00000000..24b6b376 --- /dev/null +++ b/packages/python/ess-load-test/Pulumi.example.yaml @@ -0,0 +1,11 @@ +# Copy to Pulumi..yaml (for example Pulumi.dev.yaml), then replace +# your-aws-profile with the AWS CLI profile for your account (after your AWS SSO login). +config: + aws:profile: your-aws-profile + aws:region: us-east-1 + ess-load-test:environment: dev + ess-load-test:instanceType: t3.large + ess-load-test:masterInstanceType: t3.small + ess-load-test:workerCount: 2 + ess-load-test:useDefaultVpc: true +encryptionsalt: v1:placeholder:placeholder diff --git a/packages/python/ess-load-test/Pulumi.yaml b/packages/python/ess-load-test/Pulumi.yaml new file mode 100644 index 00000000..d934f9d1 --- /dev/null +++ b/packages/python/ess-load-test/Pulumi.yaml @@ -0,0 +1,7 @@ +name: ess-load-test +runtime: + name: python + options: + toolchain: uv +main: src/ess_load_test/infra +description: Generic EC2 Locust load testing cluster provisioned with Pulumi diff --git a/packages/python/ess-load-test/README.md b/packages/python/ess-load-test/README.md new file mode 100644 index 00000000..6dc4d6de --- /dev/null +++ b/packages/python/ess-load-test/README.md @@ -0,0 +1,45 @@ +# ess-load-test + +Generic EC2 Locust load testing engine. A shell package calls `build_cli()` with an +`AppConfig` and `deploy_cluster()` with its own paths, tags, and Locust scenarios. + +```bash +cd packages/python/ess-load-test +uv run ess-load-test run --help +``` + +## Installation + +From the workspace root: + +```bash +uv sync --all-packages +``` + +## Usage + +```bash +uv run ess-load-test status --stack dev --profile your-aws-profile +uv run ess-load-test run --url https://example.com --stack dev +``` + +## Shell settings + +The standalone CLI ships no organization defaults. A shell sets these on its `AppConfig`: + +| Field | Effect | Default | +|-------|--------|---------| +| `oauth.default_token_url` | OAuth token URL used when `LOAD_TEST_OAUTH_TOKEN_URL` is unset | `""` (the env var is required) | +| `extra_auth_domains` | IdP hosts that browser auth should treat as an SSO login, beyond `ess_browser.AUTH_DOMAINS` | `()` | +| `project_tag` | `Project` tag on every cluster resource; pass it to `deploy_cluster(project_tag=...)` | `component_name` | + +Changing `project_tag` on a live stack retags every resource on the next deploy. + +## Pulumi network placement (shell stack YAML) + +Shell projects pass config under their namespace (e.g. `my-load-test:*`): + +| Config key | Effect | +|------------|--------| +| `useDefaultVpc` | `true` → account default VPC; `false` without overrides → error | +| `vpcId` + `subnetIds` | When **both** set, place EC2 in that VPC/subnet (first subnet used for all instances) | diff --git a/packages/python/ess-load-test/pyproject.toml b/packages/python/ess-load-test/pyproject.toml new file mode 100644 index 00000000..ff67cd36 --- /dev/null +++ b/packages/python/ess-load-test/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "ess-load-test" +version = "0.1.0" +description = "Generic EC2 Locust load testing engine and CLI" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.12,<3.13" +dependencies = [ + "boto3>=1.35.0", + "click>=8.1", + "ess-auth", + "ess-browser", + "locust>=2.32,<3", + "pulumi>=3.0.0", + "pulumi-aws>=6.0.0", + "python-dotenv>=1.0.0", +] + +[project.optional-dependencies] +dev = ["pytest>=7.0.0"] + +[project.scripts] +ess-load-test = "ess_load_test.__main__:cli_entry" + +[tool.uv.sources] +ess-auth = { workspace = true } +ess-browser = { workspace = true } + +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/ess_load_test"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/packages/python/ess-load-test/src/ess_load_test/__init__.py b/packages/python/ess-load-test/src/ess_load_test/__init__.py new file mode 100644 index 00000000..5174a7ed --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/__init__.py @@ -0,0 +1,19 @@ +"""Generic EC2 Locust load testing engine.""" + +from ess_load_test.app_config import ( + AppConfig, + LocustBundle, + LocustSyncFile, + OAuthSettings, +) +from ess_load_test.cli import build_cli +from ess_load_test.infra.stack import deploy_cluster + +__all__ = [ + "AppConfig", + "LocustBundle", + "LocustSyncFile", + "OAuthSettings", + "build_cli", + "deploy_cluster", +] diff --git a/packages/python/ess-load-test/src/ess_load_test/__main__.py b/packages/python/ess-load-test/src/ess_load_test/__main__.py new file mode 100644 index 00000000..15b7d7e4 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/__main__.py @@ -0,0 +1,25 @@ +"""CLI entry point for ess-load-test.""" + +import logging +import sys + +import click + +from ess_load_test.app_config import default_app_config +from ess_load_test.cli import build_cli + +main = build_cli(default_app_config()) + + +def cli_entry() -> None: + """Console script entry point.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + try: + main() + except click.ClickException as exc: + click.echo(str(exc), err=True) + sys.exit(1) + + +if __name__ == "__main__": + cli_entry() diff --git a/packages/python/ess-load-test/src/ess_load_test/app_config.py b/packages/python/ess-load-test/src/ess_load_test/app_config.py new file mode 100644 index 00000000..2c125850 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/app_config.py @@ -0,0 +1,105 @@ +"""Application configuration for load-test shells and the generic CLI.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class LocustSyncFile: + """Local file synced to a remote path on EC2 via SSM.""" + + local_path: Path + remote_path: str + + +@dataclass(frozen=True) +class LocustBundle: + """Set of Locust sources synced to cluster instances before a run.""" + + sync_files: tuple[LocustSyncFile, ...] + + +@dataclass(frozen=True) +class OAuthSettings: + """OAuth client-credentials environment variable names and fallbacks. + + ``default_token_url`` is used when no token-URL environment variable is + set. Leave it empty to require one. + """ + + default_token_url: str = "" + client_id_env: str = "LOAD_TEST_OAUTH_CLIENT_ID" + client_secret_env: str = "LOAD_TEST_OAUTH_CLIENT_SECRET" + token_url_env: str = "LOAD_TEST_OAUTH_TOKEN_URL" + client_id_fallback_envs: tuple[str, ...] = () + client_secret_fallback_envs: tuple[str, ...] = () + token_url_fallback_envs: tuple[str, ...] = () + + +@dataclass(frozen=True) +class AppConfig: + """Wiring for a load-test CLI shell or the generic ess-load-test tool.""" + + pulumi_project_dir: Path + locust_install_dir: str + locust_bundle: LocustBundle + valid_scenarios: frozenset[str] + scenario_choices: tuple[str, ...] + default_scenario: str + ui_default_scenario: str + default_profile: str + default_stack: str + default_region: str + oauth: OAuthSettings + browser_profile_dir: Path + component_name: str + pulumi_config_namespace: str | None = None + # IdP hosts beyond ess-browser's public AUTH_DOMAINS. Browser auth + # mistakes an unrecognized login page for the target and never waits. + extra_auth_domains: tuple[str, ...] = () + # The Project tag on every cluster resource; component_name when unset. + # Changing it on a live stack retags every resource on the next deploy. + project_tag: str | None = None + + @property + def config_namespace(self) -> str: + return self.pulumi_config_namespace or self.component_name + + +def default_app_config() -> AppConfig: + """Build generic ess-load-test defaults for the standalone CLI.""" + package_root = Path(__file__).resolve().parents[2] + install_dir = "/opt/ess-load-test" + locust_dir = Path(__file__).resolve().parent / "locust" + return AppConfig( + pulumi_project_dir=package_root, + locust_install_dir=install_dir, + locust_bundle=LocustBundle( + sync_files=( + LocustSyncFile( + local_path=locust_dir / "locustfile.py", + remote_path=f"{install_dir}/locustfile.py", + ), + LocustSyncFile( + local_path=locust_dir / "runtime.py", + remote_path=f"{install_dir}/locust_runtime.py", + ), + LocustSyncFile( + local_path=locust_dir / "scenario_default.py", + remote_path=f"{install_dir}/scenario_default.py", + ), + ), + ), + valid_scenarios=frozenset({"default"}), + scenario_choices=("default",), + default_scenario="default", + ui_default_scenario="default", + default_profile="dev", + default_stack="dev", + default_region="us-east-1", + oauth=OAuthSettings(), + browser_profile_dir=Path.home() / ".ess-load-test" / "browser-profile", + component_name="ess-load-test", + ) diff --git a/packages/python/ess-load-test/src/ess_load_test/auth/__init__.py b/packages/python/ess-load-test/src/ess_load_test/auth/__init__.py new file mode 100644 index 00000000..272178d3 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/auth/__init__.py @@ -0,0 +1,25 @@ +"""Authentication helpers for load test targets.""" + +from ess_load_test.auth.browser import ( + BrowserAuthError, + establish_browser_session, + fetch_auth_header_via_browser, +) +from ess_load_test.auth.oauth import ( + OAuthCredentialsError, + fetch_bearer_token, + format_auth_header, + resolve_oauth_credentials, + write_auth_header_env_file, +) + +__all__ = [ + "BrowserAuthError", + "OAuthCredentialsError", + "establish_browser_session", + "fetch_auth_header_via_browser", + "fetch_bearer_token", + "format_auth_header", + "resolve_oauth_credentials", + "write_auth_header_env_file", +] diff --git a/packages/python/ess-load-test/src/ess_load_test/auth/browser.py b/packages/python/ess-load-test/src/ess_load_test/auth/browser.py new file mode 100644 index 00000000..1950afe3 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/auth/browser.py @@ -0,0 +1,201 @@ +"""Browser-based SSO auth via system Chrome (ess-browser).""" + +import logging +import time +from pathlib import Path +from urllib.parse import urlparse + +from ess_browser import LOGIN_TIMEOUT_MS, BrowserSession +from playwright.sync_api import Page, Request + +logger = logging.getLogger(__name__) + +_NEXT_AUTH_COOKIE_NAMES = frozenset( + { + "next-auth.session-token", + "__Secure-next-auth.session-token", + "__Host-next-auth.session-token", + } +) + + +class BrowserAuthError(ValueError): + """Raised when browser SSO auth cannot produce a load-test header.""" + + +def _normalize_host(hostname: str) -> str: + return hostname.lower().removeprefix("www.") + + +def _host_matches(request_host: str, target_host: str) -> bool: + normalized_request = _normalize_host(request_host) + normalized_target = _normalize_host(target_host) + return normalized_request == normalized_target or normalized_request.endswith( + f".{normalized_target}" + ) + + +def _authorization_from_request(request: Request, target_host: str) -> str | None: + auth_value = request.headers.get("authorization") + if not auth_value: + return None + request_host = urlparse(request.url).hostname + if not request_host or not _host_matches(request_host, target_host): + return None + if auth_value.lower().startswith("bearer "): + return f"Authorization: {auth_value}" + return f"Authorization: Bearer {auth_value}" + + +def _cookies_to_header(page: Page, target_url: str) -> str | None: + cookies = page.context.cookies([target_url]) + if not cookies: + return None + cookie_pairs = [f"{cookie['name']}={cookie['value']}" for cookie in cookies] + return f"Cookie: {'; '.join(cookie_pairs)}" + + +def _has_auth_session(page: Page, target_url: str) -> bool: + """Return True when NextAuth session cookies are present for the target.""" + cookies = page.context.cookies([target_url]) + return any(cookie["name"] in _NEXT_AUTH_COOKIE_NAMES for cookie in cookies) + + +def _has_any_cookies(page: Page, target_url: str) -> bool: + return bool(page.context.cookies([target_url])) + + +def _wait_for_target_session( + page: Page, + target_url: str, + *, + headed: bool, + captured_headers: list[str], + extra_auth_domains: tuple[str, ...] = (), +) -> None: + """Navigate to the target and wait until SSO produces a usable session.""" + page.goto(target_url, wait_until="load", timeout=LOGIN_TIMEOUT_MS) + deadline = time.time() + LOGIN_TIMEOUT_MS / 1000 + + if headed: + logger.info( + "Complete SSO in the Chrome window. The browser closes automatically " + "once login is detected." + ) + + while time.time() < deadline: + if captured_headers: + return + if BrowserSession.is_auth_redirect( + page.url, + target_url, + extra_auth_domains=extra_auth_domains, + ): + if not headed: + msg = ( + "SSO login is required but the browser is headless. " + "Re-run with --headed to complete login in system Chrome." + ) + raise BrowserAuthError(msg) + remaining_ms = max(1000, int((deadline - time.time()) * 1000)) + BrowserSession.wait_for_login( + page, + target_url, + timeout_ms=remaining_ms, + ) + elif _has_auth_session(page, target_url): + return + elif not headed and _has_any_cookies(page, target_url): + return + time.sleep(0.5) + + msg = ( + "Timed out waiting for browser SSO login. " + "Re-run with --headed and complete SSO in Chrome." + ) + raise BrowserAuthError(msg) + + +def establish_browser_session( + target_url: str, + *, + profile_dir: Path, + headed: bool = True, + extra_auth_domains: tuple[str, ...] = (), +) -> None: + """Open system Chrome and wait until SSO session cookies are saved. + + ``extra_auth_domains`` names IdP hosts beyond ess-browser's public + ``AUTH_DOMAINS``, such as your organization's own login host. + """ + if not target_url.strip(): + msg = "Target URL is required for browser login." + raise BrowserAuthError(msg) + + captured_headers: list[str] = [] + + with BrowserSession(headed=headed, profile_dir=profile_dir) as session: + page = session.new_page() + _wait_for_target_session( + page, + target_url, + headed=headed, + captured_headers=captured_headers, + extra_auth_domains=extra_auth_domains, + ) + + logger.info("Browser session saved in %s", profile_dir) + + +def fetch_auth_header_via_browser( + target_url: str, + *, + profile_dir: Path, + headed: bool = True, + extra_auth_domains: tuple[str, ...] = (), +) -> str: + """Log in via system Chrome and return a Locust auth header value. + + ``extra_auth_domains`` names IdP hosts beyond ess-browser's public + ``AUTH_DOMAINS``, such as your organization's own login host. + """ + if not target_url.strip(): + msg = "Target URL is required for browser auth." + raise BrowserAuthError(msg) + + target_host = urlparse(target_url).hostname + if not target_host: + msg = f"Invalid target URL for browser auth: {target_url!r}" + raise BrowserAuthError(msg) + + captured_headers: list[str] = [] + + def on_request(request: Request) -> None: + header = _authorization_from_request(request, target_host) + if header: + captured_headers.append(header) + + with BrowserSession(headed=headed, profile_dir=profile_dir) as session: + page = session.new_page() + page.on("request", on_request) + _wait_for_target_session( + page, + target_url, + headed=headed, + captured_headers=captured_headers, + extra_auth_domains=extra_auth_domains, + ) + + if captured_headers: + return captured_headers[-1] + + cookie_header = _cookies_to_header(page, target_url) + if cookie_header: + return cookie_header + + msg = ( + "Browser login completed but no Authorization header or session cookies " + f"were found for {target_url}. Try `browser login --headed` first, " + "or set LOAD_TEST_AUTH_HEADER manually." + ) + raise BrowserAuthError(msg) diff --git a/packages/python/ess-load-test/src/ess_load_test/auth/oauth.py b/packages/python/ess-load-test/src/ess_load_test/auth/oauth.py new file mode 100644 index 00000000..fc5b434b --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/auth/oauth.py @@ -0,0 +1,74 @@ +"""OAuth2 client-credentials token fetch for load test targets.""" + +import os +import shlex + +from ess_auth import AuthenticationError, TokenHelper + +from ess_load_test.app_config import OAuthSettings + + +class OAuthCredentialsError(ValueError): + """Raised when OAuth client credentials are missing or invalid.""" + + +def _first_env(names: tuple[str, ...]) -> str | None: + for name in names: + value = os.environ.get(name) + if value: + return value + return None + + +def resolve_oauth_credentials(oauth: OAuthSettings) -> tuple[str, str, str]: + """Resolve client ID, secret, and token URL from environment variables.""" + client_id = _first_env((oauth.client_id_env, *oauth.client_id_fallback_envs)) + client_secret = _first_env( + (oauth.client_secret_env, *oauth.client_secret_fallback_envs) + ) + token_url = ( + _first_env((oauth.token_url_env, *oauth.token_url_fallback_envs)) + or oauth.default_token_url + ) + if not client_id or not client_secret: + msg = ( + f"OAuth client credentials are not set. Provide {oauth.client_id_env} " + f"and {oauth.client_secret_env} in the environment." + ) + raise OAuthCredentialsError(msg) + if not token_url: + msg = ( + f"OAuth token URL is not set. Provide {oauth.token_url_env} in the " + "environment, or set OAuthSettings.default_token_url in your shell." + ) + raise OAuthCredentialsError(msg) + return client_id, client_secret, token_url + + +def fetch_bearer_token(oauth: OAuthSettings) -> str: + """Fetch an access token via client credentials.""" + client_id, client_secret, token_url = resolve_oauth_credentials(oauth) + helper = TokenHelper(client_id, client_secret, token_url=token_url) + try: + return helper.fetch_token() + except AuthenticationError as exc: + raise OAuthCredentialsError(str(exc)) from exc + + +def format_auth_header(token: str) -> str: + """Format a token as an Authorization header value for Locust.""" + stripped = token.strip() + if stripped.lower().startswith("bearer "): + return f"Authorization: {stripped}" + if ":" in stripped: + return stripped + return f"Authorization: Bearer {stripped}" + + +def write_auth_header_env_file(path: str, token: str) -> None: + """Write a shell-exportable LOAD_TEST_AUTH_HEADER assignment.""" + header = format_auth_header(token) + content = f"export LOAD_TEST_AUTH_HEADER={shlex.quote(header)}\n" + with open(path, "w", encoding="utf-8") as env_file: + env_file.write(content) + os.chmod(path, 0o600) diff --git a/packages/python/ess-load-test/src/ess_load_test/cli/__init__.py b/packages/python/ess-load-test/src/ess_load_test/cli/__init__.py new file mode 100644 index 00000000..c6a9e836 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/cli/__init__.py @@ -0,0 +1,5 @@ +"""CLI factory for ess-load-test.""" + +from ess_load_test.cli.build_cli import build_cli + +__all__ = ["build_cli"] diff --git a/packages/python/ess-load-test/src/ess_load_test/cli/_helpers.py b/packages/python/ess-load-test/src/ess_load_test/cli/_helpers.py new file mode 100644 index 00000000..ec2cc381 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/cli/_helpers.py @@ -0,0 +1,267 @@ +"""Shared Click helpers for load-test CLI commands.""" + +# pylint: disable=too-many-arguments,too-many-positional-arguments + +from pathlib import Path + +import click + +from ess_load_test.app_config import AppConfig +from ess_load_test.auth import ( + BrowserAuthError, + OAuthCredentialsError, + establish_browser_session, + fetch_auth_header_via_browser, + fetch_bearer_token, + format_auth_header, +) +from ess_load_test.cli.load_params import LoadTestParams +from ess_load_test.cluster import StackOutputError +from ess_load_test.ui import UiWorkflowConfig, UiWorkflowError, resolve_auth_header + +ENV_TARGET_URL = "LOAD_TEST_TARGET_URL" +ENV_AWS_PROFILE = "LOAD_TEST_AWS_PROFILE" +ENV_STACK = "LOAD_TEST_STACK" +ENV_AWS_REGION = "LOAD_TEST_AWS_REGION" +ENV_SCENARIO = "LOAD_TEST_SCENARIO" + + +def _browser_profile_dir(app_config: AppConfig, override: str | None) -> Path: + if override: + return Path(override) + return app_config.browser_profile_dir + + +def resolve_cli_auth_header( # noqa: PLR0913 + app_config: AppConfig, + explicit_header: str | None, + *, + fetch_token: bool, + browser_auth: bool = False, + target_url: str = "", + headed: bool = False, + browser_profile_dir: str | None = None, +) -> str | None: + profile_dir = _browser_profile_dir(app_config, browser_profile_dir) + if browser_auth: + + def resolve_browser() -> str: + return fetch_auth_header_via_browser( + target_url, + headed=headed, + profile_dir=profile_dir, + extra_auth_domains=app_config.extra_auth_domains, + ) + + return resolve_auth_header( + explicit_header, + fetch_token=fetch_token, + resolve_token=resolve_browser, + ) + + return resolve_auth_header( + explicit_header, + fetch_token=fetch_token, + resolve_token=lambda: format_auth_header(fetch_bearer_token(app_config.oauth)), + ) + + +def make_resolve_token(config: UiWorkflowConfig): + profile_dir = _browser_profile_dir( + config.app_config, + config.browser_profile_dir, + ) + if config.browser_auth: + return lambda: fetch_auth_header_via_browser( + config.target_url, + headed=config.headed, + profile_dir=profile_dir, + extra_auth_domains=config.app_config.extra_auth_domains, + ) + return lambda: format_auth_header(fetch_bearer_token(config.app_config.oauth)) + + +def browser_auth_options(app_config: AppConfig): + """Decorator factory for system-Chrome SSO auth options.""" + + def decorator(func): + func = click.option( + "--browser-auth", + is_flag=True, + help=( + "Authenticate via system Chrome SSO instead of OAuth client " + "credentials." + ), + )(func) + func = click.option( + "--headed", + is_flag=True, + default=False, + help="Show system Chrome (required on first browser SSO login).", + )(func) + func = click.option( + "--browser-profile-dir", + default=None, + help=( + f"Chrome profile directory [default: {app_config.browser_profile_dir}]" + ), + )(func) + return func + + return decorator + + +def scenario_option(app_config: AppConfig, *, default: str, show_default: bool = True): + """Decorator factory for Locust scenario selection.""" + + def decorator(func): + return click.option( + "--scenario", + default=default, + envvar=ENV_SCENARIO, + show_default=show_default, + type=click.Choice(app_config.scenario_choices), + help="Locust user scenario.", + )(func) + + return decorator + + +def workflow_options(app_config: AppConfig): + """Decorator factory for shared UI workflow options.""" + + def decorator(func): + func = click.option( + "--path", + "paths", + default="/", + show_default=True, + help="Comma-separated HTTP paths to request.", + )(func) + func = click.option( + "--auth-header", + default=None, + envvar="LOAD_TEST_AUTH_HEADER", + help="Optional auth header value or 'Header-Name: value'.", + )(func) + func = click.option( + "--fetch-token/--no-fetch-token", + default=True, + show_default=True, + help=( + "Fetch OAuth token when credentials are set and --auth-header " + "is omitted." + ), + )(func) + func = click.option( + "--stack", + default=app_config.default_stack, + envvar=ENV_STACK, + show_default=True, + help="Pulumi stack name.", + )(func) + func = click.option( + "--profile", + default=app_config.default_profile, + envvar=ENV_AWS_PROFILE, + show_default=True, + help="AWS profile name.", + )(func) + func = click.option( + "--region", + default=None, + envvar=ENV_AWS_REGION, + help="AWS region (defaults to stack output).", + )(func) + func = click.option( + "--local-port", + default=8089, + show_default=True, + help="Local port for Locust UI port-forward.", + )(func) + return func + + return decorator + + +def workflow_config( # noqa: PLR0913 + app_config: AppConfig, + *, + url: str | None, + paths: str, + scenario: str | None = None, + auth_header: str | None, + fetch_token: bool, + stack: str, + profile: str, + region: str | None, + local_port: int, + browser_auth: bool = False, + headed: bool = False, + browser_profile_dir: str | None = None, + load_params: LoadTestParams | None = None, +) -> UiWorkflowConfig: + return UiWorkflowConfig( + app_config=app_config, + target_url=url or "", + paths=paths, + scenario=scenario, + stack=stack, + profile=profile, + region=region, + local_port=local_port, + fetch_token=fetch_token, + auth_header=auth_header, + browser_auth=browser_auth, + headed=headed, + browser_profile_dir=browser_profile_dir, + load_params=load_params, + ) + + +def handle_workflow_error(exc: Exception) -> None: + if isinstance( + exc, + (StackOutputError, UiWorkflowError, OAuthCredentialsError, BrowserAuthError), + ): + raise click.ClickException(str(exc)) from exc + raise exc + + +def browser_login_command_factory(app_config: AppConfig): + @click.command("login") + @click.option( + "--url", + required=True, + envvar=ENV_TARGET_URL, + help="Target base URL (scheme + host).", + ) + @click.option( + "--headed/--headless", + default=True, + show_default=True, + help="Show system Chrome (use --headless only with a saved profile).", + ) + @click.option( + "--browser-profile-dir", + default=None, + help=(f"Chrome profile directory [default: {app_config.browser_profile_dir}]"), + ) + def browser_login_command( + url: str, + headed: bool, + browser_profile_dir: str | None, + ) -> None: + """Open system Chrome, complete SSO, and save the session profile.""" + try: + establish_browser_session( + url, + headed=headed, + profile_dir=_browser_profile_dir(app_config, browser_profile_dir), + extra_auth_domains=app_config.extra_auth_domains, + ) + except BrowserAuthError as exc: + raise click.ClickException(str(exc)) from exc + click.echo("OK: browser session established") + + return browser_login_command diff --git a/packages/python/ess-load-test/src/ess_load_test/cli/build_cli.py b/packages/python/ess-load-test/src/ess_load_test/cli/build_cli.py new file mode 100644 index 00000000..b8343488 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/cli/build_cli.py @@ -0,0 +1,638 @@ +"""Click CLI factory for load-test tools.""" + +# pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals + +import click +from botocore.exceptions import NoCredentialsError, NoRegionError, ProfileNotFound + +from ess_load_test.app_config import AppConfig +from ess_load_test.auth import ( + BrowserAuthError, + OAuthCredentialsError, + fetch_auth_header_via_browser, + fetch_bearer_token, + format_auth_header, + write_auth_header_env_file, +) +from ess_load_test.cli import _helpers as helpers +from ess_load_test.cli.load_params import ( + load_test_params_from_click, + load_test_params_options, +) +from ess_load_test.cluster import ( + LoadTestRunRequest, + StackOutputError, + load_stack_outputs, + reset_cluster_locust, + run_load_test, +) +from ess_load_test.ui import ( + LocalPortExpectation, + UiWorkflowError, + check_local_port, + cleanup_stale_port_forward, + open_locust_ui, + print_cluster_status, + run_port_forward, + run_preflight, +) + + +# One closure per command, all sharing app_config, so the size and branch +# count grow with the command list rather than with any one command. +def build_cli(app_config: AppConfig) -> click.Command: # noqa: C901, PLR0915 + """Build a Click command group for load-test operations.""" + + @click.group() + def cli() -> None: + """EC2 Locust load testing for HTTP targets.""" + + @click.group() + def browser() -> None: + """System Chrome SSO login for load-test targets.""" + + browser.add_command(helpers.browser_login_command_factory(app_config)) + + @click.group() + def ui() -> None: + """Locust web UI: token, cluster start, and SSM port-forward.""" + + @ui.command("open") + @helpers.workflow_options(app_config) + @helpers.browser_auth_options(app_config) + @helpers.scenario_option(app_config, default=app_config.ui_default_scenario) + @load_test_params_options(duration_default="10m", headless_exports=False) + @click.option( + "--url", + required=True, + envvar=helpers.ENV_TARGET_URL, + help="Target base URL (scheme + host).", + ) + def ui_open_command( # noqa: PLR0913 + url: str, + paths: str, + scenario: str, + users: int, + spawn_rate: float, + duration: str | None, + user_class: tuple[str, ...], + autostart: bool, + autoquit: int | None, + auth_header: str | None, + fetch_token: bool, + stack: str, + profile: str, + region: str | None, + local_port: int, + browser_auth: bool, + headed: bool, + browser_profile_dir: str | None, + ) -> None: + """Preflight, fetch auth, start Locust UI, and print port-forward.""" + load_params = load_test_params_from_click( + users=users, + spawn_rate=spawn_rate, + duration=duration, + user_class=user_class, + autostart=autostart, + autoquit=autoquit, + ) + config = helpers.workflow_config( + app_config, + url=url, + paths=paths, + scenario=scenario, + auth_header=auth_header, + fetch_token=fetch_token, + stack=stack, + profile=profile, + region=region, + local_port=local_port, + browser_auth=browser_auth, + headed=headed, + browser_profile_dir=browser_profile_dir, + load_params=load_params, + ) + try: + _, forward_command = open_locust_ui( + config, + resolve_token=helpers.make_resolve_token(config), + ) + print_cluster_status(config) + except ( + RuntimeError, + TimeoutError, + OAuthCredentialsError, + BrowserAuthError, + UiWorkflowError, + ) as exc: + helpers.handle_workflow_error(exc) + + click.echo("") + click.echo("Locust web UI started on the master (port 8089).") + click.echo(f"Open http://localhost:{local_port} after port-forward.") + click.echo("Port-forward command:") + click.echo(forward_command) + + @ui.command("forward") + @helpers.workflow_options(app_config) + @click.option( + "--exec", + "exec_forward", + is_flag=True, + help="Run port-forward (blocking).", + ) + def ui_forward_command( # noqa: PLR0913 + paths: str, + auth_header: str | None, + fetch_token: bool, + stack: str, + profile: str, + region: str | None, + local_port: int, + exec_forward: bool, + ) -> None: + """Print or run the SSM port-forward command for the Locust web UI.""" + config = helpers.workflow_config( + app_config, + url="", + paths=paths, + auth_header=auth_header, + fetch_token=fetch_token, + stack=stack, + profile=profile, + region=region, + local_port=local_port, + ) + try: + forward_command = run_port_forward(config, exec_forward=exec_forward) + except StackOutputError as exc: + helpers.handle_workflow_error(exc) + + if not exec_forward: + click.echo("Start the Locust master UI first (ui open), then:") + click.echo(forward_command) + + @ui.command("cleanup-forward") + @click.option( + "--local-port", + default=8089, + show_default=True, + help="Local Locust UI port to clear.", + ) + def ui_cleanup_forward_command(local_port: int) -> None: + """Kill stale local SSM port-forward processes and verify the port is free.""" + try: + killed = cleanup_stale_port_forward(local_port) + except RuntimeError as exc: + raise click.ClickException(str(exc)) from exc + + if killed: + click.echo( + f"Stopped stale port-forward process(es) ({killed} pkill match(es))." + ) + else: + click.echo("No stale port-forward processes found.") + click.echo(f"OK: local port {local_port} is free") + + @ui.command("check-port") + @click.option( + "--local-port", + default=8089, + show_default=True, + help="Local Locust UI port to inspect.", + ) + @click.option( + "--expect", + type=click.Choice(["free", "listening"], case_sensitive=False), + default="free", + show_default=True, + help="Exit 0 when the port matches this state (lsof exit 1 => free).", + ) + def ui_check_port_command(local_port: int, expect: str) -> None: + """Check local port state; exit 0 when --expect is met.""" + expectation = LocalPortExpectation(expect.lower()) + try: + check_local_port(local_port, expect=expectation) + except RuntimeError as exc: + raise click.ClickException(str(exc)) from exc + + if expectation == LocalPortExpectation.FREE: + click.echo(f"OK: port {local_port} is free") + else: + click.echo(f"OK: port {local_port} is listening") + + @ui.command("reset-cluster") + @click.option( + "--stack", + default=app_config.default_stack, + envvar=helpers.ENV_STACK, + show_default=True, + help="Pulumi stack name.", + ) + @click.option( + "--profile", + default=app_config.default_profile, + envvar=helpers.ENV_AWS_PROFILE, + show_default=True, + help="AWS profile name.", + ) + @click.option( + "--region", + default=app_config.default_region, + envvar=helpers.ENV_AWS_REGION, + show_default=True, + help="AWS region.", + ) + def ui_reset_cluster_command(stack: str, profile: str, region: str) -> None: + """Stop stale Locust processes on all EC2 instances. + + Frees the master ports 5557–5558 and the web UI port 8089. + """ + try: + reset_cluster_locust( + stack=stack, + profile=profile, + region=region, + app_config=app_config, + ) + except (StackOutputError, RuntimeError, TimeoutError) as exc: + raise click.ClickException(str(exc)) from exc + click.echo("OK: Locust processes stopped on all cluster instances") + + @ui.command("preflight") + @helpers.workflow_options(app_config) + @click.option( + "--url", + required=True, + envvar=helpers.ENV_TARGET_URL, + help="Target base URL (scheme + host).", + ) + def ui_preflight_command( # noqa: PLR0913 + url: str, + paths: str, + auth_header: str | None, + fetch_token: bool, + stack: str, + profile: str, + region: str | None, + local_port: int, + ) -> None: + """Check AWS credentials and target URL before starting Locust.""" + config = helpers.workflow_config( + app_config, + url=url, + paths=paths, + auth_header=auth_header, + fetch_token=fetch_token, + stack=stack, + profile=profile, + region=region, + local_port=local_port, + ) + try: + run_preflight(config) + except UiWorkflowError as exc: + helpers.handle_workflow_error(exc) + click.echo("OK: preflight passed") + + cli.add_command(browser) + cli.add_command(ui) + + @click.command("fetch-token") + @click.option( + "--url", + default=None, + envvar=helpers.ENV_TARGET_URL, + help="Target base URL (required for --browser-auth).", + ) + @click.option( + "--browser-auth", + is_flag=True, + help="Log in via system Chrome SSO instead of OAuth client credentials.", + ) + @click.option( + "--headed", + is_flag=True, + default=False, + help="Show system Chrome (use on first browser SSO login).", + ) + @click.option( + "--browser-profile-dir", + default=None, + help=(f"Chrome profile directory [default: {app_config.browser_profile_dir}]"), + ) + @click.option( + "--write-env-file", + type=click.Path(dir_okay=False), + default=None, + help="Write export LOAD_TEST_AUTH_HEADER=... to this file (mode 600).", + ) + @click.option( + "--print-header/--no-print-header", + default=False, + help="Print the auth header to stdout (avoid in shared logs).", + ) + def fetch_token_command( # noqa: PLR0913 + url: str | None, + browser_auth: bool, + headed: bool, + browser_profile_dir: str | None, + write_env_file: str | None, + print_header: bool, + ) -> None: + """Fetch auth for load tests (client credentials or system Chrome SSO).""" + try: + if browser_auth: + if not url: + raise click.ClickException( + "--url or LOAD_TEST_TARGET_URL is required for --browser-auth" + ) + header = fetch_auth_header_via_browser( + url, + headed=headed, + profile_dir=helpers._browser_profile_dir( # noqa: SLF001 + app_config, + browser_profile_dir, + ), + extra_auth_domains=app_config.extra_auth_domains, + ) + else: + token = fetch_bearer_token(app_config.oauth) + header = format_auth_header(token) + except (OAuthCredentialsError, BrowserAuthError) as exc: + raise click.ClickException(str(exc)) from exc + + if write_env_file: + write_auth_header_env_file(write_env_file, header) + click.echo(f"Wrote auth header env file: {write_env_file}") + if print_header: + click.echo(header) + if not write_env_file and not print_header: + click.echo( + "Auth header fetched. Use --write-env-file or --print-header " + "to export it." + ) + + @click.command("run") + @helpers.browser_auth_options(app_config) + @click.option( + "--url", + required=True, + envvar=helpers.ENV_TARGET_URL, + help="Target base URL (scheme + host).", + ) + @click.option( + "--path", + "paths", + default="/", + show_default=True, + help="Comma-separated HTTP paths to request.", + ) + @load_test_params_options(duration_default="5m", headless_exports=True) + @click.option( + "--auth-header", + default=None, + envvar="LOAD_TEST_AUTH_HEADER", + help="Optional auth header value or 'Header-Name: value'.", + ) + @click.option( + "--fetch-token/--no-fetch-token", + default=True, + show_default=True, + help="Fetch OAuth token when credentials are set and --auth-header is omitted.", + ) + @click.option( + "--stack", + default=app_config.default_stack, + envvar=helpers.ENV_STACK, + show_default=True, + help="Pulumi stack name.", + ) + @click.option( + "--profile", + default=None, + envvar=helpers.ENV_AWS_PROFILE, + help="AWS profile name.", + ) + @click.option( + "--region", + default=None, + envvar=helpers.ENV_AWS_REGION, + help="AWS region (defaults to stack output).", + ) + @helpers.scenario_option(app_config, default=app_config.default_scenario) + def run_command( # noqa: PLR0913 + scenario: str, + browser_auth: bool, + headed: bool, + browser_profile_dir: str | None, + url: str, + paths: str, + users: int, + spawn_rate: float, + duration: str | None, + user_class: tuple[str, ...], + autostart: bool, + autoquit: int | None, + csv_prefix: str | None, + html_path: str | None, + reset_stats: bool, + auth_header: str | None, + fetch_token: bool, + stack: str, + profile: str | None, + region: str | None, + ) -> None: + """Run a headless Locust load test against a URL.""" + load_params = load_test_params_from_click( + users=users, + spawn_rate=spawn_rate, + duration=duration, + user_class=user_class, + autostart=autostart, + autoquit=autoquit, + csv_prefix=csv_prefix, + html_path=html_path, + reset_stats=reset_stats, + ) + try: + outputs = load_stack_outputs(stack, app_config.pulumi_project_dir) + resolved_region = region or str(outputs.get("region", "")) + resolved_auth = auth_header + if fetch_token and not resolved_auth: + resolved_auth = helpers.resolve_cli_auth_header( + app_config, + None, + fetch_token=True, + browser_auth=browser_auth, + target_url=url, + headed=headed, + browser_profile_dir=browser_profile_dir, + ) + request = LoadTestRunRequest( + stack=stack, + target_url=url, + paths=paths, + scenario=scenario, + load_params=load_params, + auth_header=resolved_auth, + profile=profile, + region=resolved_region or None, + ) + stdout = run_load_test(request, app_config) + except ( + StackOutputError, + RuntimeError, + TimeoutError, + OAuthCredentialsError, + BrowserAuthError, + NoCredentialsError, + NoRegionError, + ProfileNotFound, + ) as exc: + raise click.ClickException(str(exc)) from exc + + click.echo(stdout) + + @click.command("status") + @click.option( + "--stack", + default=app_config.default_stack, + envvar=helpers.ENV_STACK, + show_default=True, + help="Pulumi stack name.", + ) + @click.option( + "--profile", + default=app_config.default_profile, + envvar=helpers.ENV_AWS_PROFILE, + show_default=True, + help="AWS profile name.", + ) + @click.option( + "--region", + default=None, + envvar=helpers.ENV_AWS_REGION, + help="AWS region (defaults to stack output).", + ) + def status_command(stack: str, profile: str, region: str | None) -> None: + """Show Pulumi outputs and SSM reachability for the load test cluster.""" + config = helpers.workflow_config( + app_config, + url="https://app.example.com", + paths="/", + auth_header=None, + fetch_token=False, + stack=stack, + profile=profile, + region=region, + local_port=8089, + ) + try: + print_cluster_status(config) + except (StackOutputError, UiWorkflowError) as exc: + raise click.ClickException(str(exc)) from exc + + @click.command("ui-start", hidden=True) + @click.option( + "--url", + required=True, + envvar=helpers.ENV_TARGET_URL, + help="Target base URL (scheme + host).", + ) + @click.option("--path", "paths", default="/", show_default=True) + @click.option("--auth-header", default=None, envvar="LOAD_TEST_AUTH_HEADER") + @click.option("--fetch-token/--no-fetch-token", default=True, show_default=True) + @click.option( + "--stack", + default=app_config.default_stack, + envvar=helpers.ENV_STACK, + show_default=True, + ) + @click.option( + "--profile", + default=app_config.default_profile, + envvar=helpers.ENV_AWS_PROFILE, + show_default=True, + ) + @click.option( + "--region", + default=app_config.default_region, + envvar=helpers.ENV_AWS_REGION, + show_default=True, + ) + def ui_start_legacy_command( # noqa: PLR0913 + url: str, + paths: str, + auth_header: str | None, + fetch_token: bool, + stack: str, + profile: str, + region: str, + ) -> None: + """Deprecated: use `ui open`.""" + ctx = click.get_current_context() + ctx.invoke( + ui_open_command, + url=url, + paths=paths, + scenario=app_config.ui_default_scenario, + auth_header=auth_header, + fetch_token=fetch_token, + stack=stack, + profile=profile, + region=region, + local_port=8089, + browser_auth=False, + headed=False, + browser_profile_dir=None, + ) + + @click.command("ui-forward", hidden=True) + @click.option( + "--stack", + default=app_config.default_stack, + envvar=helpers.ENV_STACK, + show_default=True, + ) + @click.option( + "--profile", + default=app_config.default_profile, + envvar=helpers.ENV_AWS_PROFILE, + show_default=True, + ) + @click.option( + "--region", + default=app_config.default_region, + envvar=helpers.ENV_AWS_REGION, + show_default=True, + ) + @click.option("--local-port", default=8089, show_default=True) + def ui_forward_legacy_command( + stack: str, + profile: str, + region: str, + local_port: int, + ) -> None: + """Deprecated: use `ui forward`.""" + ctx = click.get_current_context() + ctx.invoke( + ui_forward_command, + paths="/", + auth_header=None, + fetch_token=True, + stack=stack, + profile=profile, + region=region, + local_port=local_port, + exec_forward=False, + ) + + cli.add_command(fetch_token_command) + cli.add_command(run_command) + cli.add_command(status_command) + cli.add_command(ui_start_legacy_command) + cli.add_command(ui_forward_legacy_command) + + return cli diff --git a/packages/python/ess-load-test/src/ess_load_test/cli/load_params.py b/packages/python/ess-load-test/src/ess_load_test/cli/load_params.py new file mode 100644 index 00000000..0d33be68 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/cli/load_params.py @@ -0,0 +1,180 @@ +"""Shared Locust load-test parameters for CLI and SSM runner.""" + +# pylint: disable=too-many-arguments,too-many-positional-arguments + +from __future__ import annotations + +import json +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import click + +ENV_USERS = "LOAD_TEST_USERS" +ENV_SPAWN_RATE = "LOAD_TEST_SPAWN_RATE" +ENV_DURATION = "LOAD_TEST_DURATION" + + +@dataclass(frozen=True) +class LoadTestParams: + """Locust user count, spawn rate, and optional run controls.""" + + users: int = 50 + spawn_rate: float = 5.0 + duration: str | None = None + user_classes: tuple[str, ...] = () + autostart: bool = False + autoquit: int | None = None + csv_prefix: str | None = None + html_path: str | None = None + reset_stats: bool = False + + +def config_users_json(user_classes: tuple[str, ...]) -> str | None: + """Build Locust --config-users JSON from user class names.""" + if not user_classes: + return None + configs = [{"user_class_name": name} for name in user_classes] + return json.dumps(configs) + + +def build_locust_load_flag_lines( + params: LoadTestParams, + *, + headless: bool, + shell_quote: Callable[[str], str], +) -> list[str]: + """Return Locust CLI flag lines for master command assembly.""" + lines = [ + f"-u {params.users}", + f"-r {params.spawn_rate}", + ] + if params.duration is not None: + lines.append(f"-t {shell_quote(params.duration)}") + config_users = config_users_json(params.user_classes) + if config_users is not None: + lines.append(f"--config-users {shell_quote(config_users)}") + if headless: + lines.append("--headless") + elif params.autostart: + lines.append("--autostart") + if params.autoquit is not None: + lines.append(f"--autoquit {params.autoquit}") + if headless: + if params.csv_prefix is not None: + lines.append(f"--csv {shell_quote(params.csv_prefix)}") + if params.html_path is not None: + lines.append(f"--html {shell_quote(params.html_path)}") + if params.reset_stats: + lines.append("--reset-stats") + return lines + + +def load_test_params_from_click( # noqa: PLR0913 + *, + users: int, + spawn_rate: float, + duration: str | None, + user_class: tuple[str, ...], + autostart: bool, + autoquit: int | None, + csv_prefix: str | None = None, + html_path: str | None = None, + reset_stats: bool = False, +) -> LoadTestParams: + """Build LoadTestParams from Click option values.""" + return LoadTestParams( + users=users, + spawn_rate=spawn_rate, + duration=duration, + user_classes=user_class, + autostart=autostart, + autoquit=autoquit, + csv_prefix=csv_prefix, + html_path=html_path, + reset_stats=reset_stats, + ) + + +def load_test_params_options(*, duration_default: str | None, headless_exports: bool): + """Decorator factory for shared Locust load-test CLI options.""" + + def decorator(func: Any) -> Any: + func = click.option( + "--users", + "-u", + default=50, + show_default=True, + envvar=ENV_USERS, + help="Peak number of concurrent Locust users.", + )(func) + func = click.option( + "--spawn-rate", + "-r", + default=5.0, + show_default=True, + envvar=ENV_SPAWN_RATE, + help="Users spawned per second.", + )(func) + duration_help = ( + "Test duration (Locust time format, e.g. 5m, 30s). " + "Headless runs require a duration; UI runs pre-fill when set." + ) + if duration_default is None: + func = click.option( + "--duration", + "-t", + default=None, + envvar=ENV_DURATION, + help=duration_help, + )(func) + else: + func = click.option( + "--duration", + "-t", + default=duration_default, + show_default=True, + envvar=ENV_DURATION, + help=duration_help, + )(func) + func = click.option( + "--user-class", + "user_class", + multiple=True, + help="Locust user class to include (repeatable). Default: all.", + )(func) + func = click.option( + "--autostart", + is_flag=True, + default=False, + help="Start swarming immediately when workers connect (UI mode only).", + )(func) + func = click.option( + "--autoquit", + type=int, + default=None, + help="Quit Locust N seconds after the run finishes (requires --autostart).", + )(func) + if headless_exports: + func = click.option( + "--csv", + "csv_prefix", + default=None, + help="Write CSV stats to this path prefix (headless run only).", + )(func) + func = click.option( + "--html", + "html_path", + default=None, + help="Write HTML report to this path (headless run only).", + )(func) + func = click.option( + "--reset-stats", + is_flag=True, + default=False, + help="Reset statistics after spawn completes (headless run only).", + )(func) + return func + + return decorator diff --git a/packages/python/ess-load-test/src/ess_load_test/cluster/__init__.py b/packages/python/ess-load-test/src/ess_load_test/cluster/__init__.py new file mode 100644 index 00000000..4d123be7 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/cluster/__init__.py @@ -0,0 +1,26 @@ +"""Remote EC2 cluster operations via SSM.""" + +from ess_load_test.cli.load_params import LoadTestParams +from ess_load_test.cluster.outputs import StackOutputError, load_stack_outputs +from ess_load_test.cluster.runner import ( + LoadTestRunRequest, + LoadTestUiRequest, + build_ui_port_forward_command, + get_cluster_status, + reset_cluster_locust, + run_load_test, + start_locust_ui, +) + +__all__ = [ + "LoadTestParams", + "LoadTestRunRequest", + "LoadTestUiRequest", + "StackOutputError", + "build_ui_port_forward_command", + "get_cluster_status", + "load_stack_outputs", + "reset_cluster_locust", + "run_load_test", + "start_locust_ui", +] diff --git a/packages/python/ess-load-test/src/ess_load_test/cluster/outputs.py b/packages/python/ess-load-test/src/ess_load_test/cluster/outputs.py new file mode 100644 index 00000000..22e4b243 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/cluster/outputs.py @@ -0,0 +1,29 @@ +"""Read Pulumi stack outputs for the load test cluster.""" + +import json +import subprocess +from pathlib import Path + + +class StackOutputError(RuntimeError): + """Raised when Pulumi stack outputs cannot be loaded.""" + + +def load_stack_outputs(stack: str, project_dir: Path) -> dict[str, object]: + """Load JSON stack outputs via the Pulumi CLI.""" + try: + result = subprocess.run( + ["uv", "run", "pulumi", "stack", "output", "--json", "--stack", stack], + cwd=project_dir, + capture_output=True, + text=True, + check=True, + ) + except subprocess.CalledProcessError as exc: + msg = ( + "Failed to read Pulumi stack outputs. " + f"Run `pulumi up` in {project_dir} first.\n{exc.stderr}" + ) + raise StackOutputError(msg) from exc + + return json.loads(result.stdout) diff --git a/packages/python/ess-load-test/src/ess_load_test/cluster/runner.py b/packages/python/ess-load-test/src/ess_load_test/cluster/runner.py new file mode 100644 index 00000000..3f471bec --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/cluster/runner.py @@ -0,0 +1,682 @@ +"""SSM orchestration for distributed Locust test runs.""" + +# The remote shell scripts below are f-string templates, and a noqa comment +# inside one would become part of the script, so line length is waived here. +# ruff: noqa: E501 + +import base64 +import logging +import os +import re +import time +from collections.abc import Sequence +from dataclasses import dataclass + +import boto3 +from botocore.exceptions import ClientError + +from ess_load_test.app_config import AppConfig +from ess_load_test.cli.load_params import LoadTestParams, build_locust_load_flag_lines +from ess_load_test.cluster.outputs import load_stack_outputs +from ess_load_test.infra.constants import locust_bin, locust_venv_dir +from ess_load_test.locust.runtime import normalize_locust_host + +logger = logging.getLogger(__name__) + +_SSM_DOCUMENT = "AWS-RunShellScript" +_POLL_INTERVAL_SECONDS = 5 +_COMMAND_TIMEOUT_SECONDS = 3600 +_WEB_UI_PORT = 8089 +_LOCUST_MASTER_PORT = 5557 +_LOCUST_STATS_PORT = 5558 +_OPERATOR_SCENARIO_ENV_KEYS = frozenset( + {"LOAD_TEST_PRODUCT_SLUG", "LOAD_TEST_THINK_TIME", "LOAD_TEST_CHAT_PROMPTS"} +) + + +def _operator_scenario_env_exports() -> str: + """Export scenario tuning vars from the operator shell onto cluster workers.""" + lines: list[str] = [] + for key, value in sorted(os.environ.items()): + if not value: + continue + if key in _OPERATOR_SCENARIO_ENV_KEYS: + lines.append(f"export {key}={_shell_quote(value)}") + if not lines: + return "" + return "\n".join(lines) + "\n" + + +_VALID_ENV_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def _validate_extra_env_key(key: str) -> None: + if not _VALID_ENV_KEY.match(key): + msg = f"Invalid extra_env key {key!r}: must be an ASCII shell identifier" + raise ValueError(msg) + + +def _reset_locust_cluster_script() -> str: + return f""" +set -euo pipefail +pkill -f '[/]locust' 2>/dev/null || true +fuser -k {_LOCUST_MASTER_PORT}/tcp 2>/dev/null || true +fuser -k {_LOCUST_STATS_PORT}/tcp 2>/dev/null || true +fuser -k {_WEB_UI_PORT}/tcp 2>/dev/null || true +if pgrep -f '[/]locust' >/dev/null 2>&1; then + echo 'Locust processes still running after reset.' >&2 + pgrep -af '[/]locust' >&2 || true + exit 1 +fi +echo 'OK: Locust processes stopped and coordination ports are free' +""" + + +def _shell_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _base64_write_file_command(remote_path: str, payload_b64: str) -> str: + quoted_path = _shell_quote(remote_path) + quoted_payload = _shell_quote(payload_b64) + return f"printf '%s' {quoted_payload} | base64 -d > {quoted_path}" + + +def _auth_file_bootstrap_lines(auth_header: str | None) -> str: + """Write the auth header to a file so workers avoid shell-export mangling.""" + if not auth_header: + return "" + payload_b64 = base64.b64encode(auth_header.encode()).decode("ascii") + quoted_payload = _shell_quote(payload_b64) + return ( + "_auth_file=$(mktemp)\n" + f"printf '%s' {quoted_payload} | base64 -d > \"$_auth_file\"\n" + 'chmod 600 "$_auth_file"\n' + 'export LOAD_TEST_AUTH_HEADER_FILE="$_auth_file"\n' + ) + + +def _build_sync_locust_sources_script(app_config: AppConfig) -> str: + install_dir = app_config.locust_install_dir + lines = ["set -euo pipefail", f"mkdir -p {install_dir!r}"] + for sync_file in app_config.locust_bundle.sync_files: + payload_b64 = base64.b64encode(sync_file.local_path.read_bytes()).decode( + "ascii" + ) + lines.append(_base64_write_file_command(sync_file.remote_path, payload_b64)) + return "\n".join(lines) + + +def _bootstrap_locust_script(install_dir: str) -> str: + venv_dir = locust_venv_dir(install_dir) + pip_bin = f"{venv_dir}/bin/pip" + bin_path = locust_bin(install_dir) + return f""" +set -euo pipefail +if [[ -x {bin_path!r} ]]; then + exit 0 +fi +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y -qq python3-pip python3-venv +python3 -m venv {venv_dir!r} +{pip_bin!r} install -q 'locust>=2.32,<3' +""" + + +def _verify_master_web_ui_script() -> str: + return f""" +set -euo pipefail +for attempt in $(seq 1 30); do + if ss -tln 2>/dev/null | grep -q ':{_WEB_UI_PORT} ' || \\ + netstat -tln 2>/dev/null | grep -q ':{_WEB_UI_PORT} '; then + exit 0 + fi + sleep 2 +done +echo 'Locust web UI is not listening on port {_WEB_UI_PORT}.' >&2 +tail -30 /var/log/locust-master.log 2>/dev/null || true +exit 1 +""" + + +@dataclass(frozen=True) +class LoadTestRunRequest: + """Parameters for a distributed Locust run.""" + + stack: str + target_url: str + paths: str + scenario: str + load_params: LoadTestParams + auth_header: str | None + profile: str | None + region: str | None + extra_env: tuple[tuple[str, str], ...] = () + + +@dataclass(frozen=True) +class LoadTestUiRequest: + """Parameters for starting the Locust web UI on the cluster.""" + + stack: str + target_url: str + paths: str + scenario: str + load_params: LoadTestParams + auth_header: str | None + profile: str | None + region: str | None + extra_env: tuple[tuple[str, str], ...] = () + + +@dataclass(frozen=True) +class WorkerRuntimeEnv: + """Environment variables workers need to match the master's locustfile.""" + + scenario: str + paths: str + auth_header: str | None + extra_env: tuple[tuple[str, str], ...] = () + + +def _extra_env_exports(extra_env: tuple[tuple[str, str], ...]) -> str: + if not extra_env: + return "" + lines: list[str] = [] + for key, value in extra_env: + if not key: + continue + _validate_extra_env_key(key) + lines.append(f"export {key}={_shell_quote(value)}") + return "\n".join(lines) + "\n" + + +def _worker_runtime_env( + *, + scenario: str, + paths: str, + auth_header: str | None, + extra_env: tuple[tuple[str, str], ...] = (), +) -> WorkerRuntimeEnv: + return WorkerRuntimeEnv( + scenario=scenario, + paths=paths, + auth_header=auth_header, + extra_env=extra_env, + ) + + +def _wrap_bash_script(script: str) -> str: + """Wrap a script for SSM RunShellScript.""" + return f"bash -c {_shell_quote(script.strip())}" + + +def _create_ssm_client(profile: str | None, region: str | None): + session_kwargs: dict[str, str] = {} + if profile: + session_kwargs["profile_name"] = profile + if region: + session_kwargs["region_name"] = region + session = boto3.Session(**session_kwargs) + return session.client("ssm") + + +def _locustfile_remote_path(app_config: AppConfig) -> str: + for sync_file in app_config.locust_bundle.sync_files: + if sync_file.local_path.name == "locustfile.py": + return sync_file.remote_path + return f"{app_config.locust_install_dir}/locustfile.py" + + +def _build_worker_command( + app_config: AppConfig, + master_private_ip: str, + runtime: WorkerRuntimeEnv, +) -> str: + install_dir = app_config.locust_install_dir + locustfile_path = _locustfile_remote_path(app_config) + master_host = _shell_quote(master_private_ip) + bin_path = locust_bin(install_dir) + auth_export = "" + auth_file_bootstrap = "" + if runtime.auth_header: + auth_export = ( + f"export LOAD_TEST_AUTH_HEADER={_shell_quote(runtime.auth_header)}\n" + ) + auth_file_bootstrap = _auth_file_bootstrap_lines(runtime.auth_header) + extra_exports = _extra_env_exports(runtime.extra_env) + return f""" +set -euo pipefail +export PYTHONPATH={install_dir!r} +export LOAD_TEST_PATHS={_shell_quote(runtime.paths)} +{_scenario_export(runtime.scenario)}{_operator_scenario_env_exports()}{auth_export}{extra_exports}{auth_file_bootstrap}pkill -x locust 2>/dev/null || true +nohup {bin_path} -f {locustfile_path} --worker --master-host {master_host} \\ + > /var/log/locust-worker.log 2>&1 & +""" + + +def _scenario_export(scenario: str) -> str: + return f"export LOAD_TEST_SCENARIO={_shell_quote(scenario)}\n" + + +def _load_params_env_exports(params: LoadTestParams) -> str: + """Export load-test env vars so the Locust web UI can pre-fill Start defaults.""" + lines = [ + f"export LOAD_TEST_USERS={params.users}", + f"export LOAD_TEST_SPAWN_RATE={params.spawn_rate}", + f"export LOCUST_USERS={params.users}", + f"export LOCUST_SPAWN_RATE={params.spawn_rate}", + ] + if params.duration is not None: + duration = _shell_quote(params.duration) + lines.append(f"export LOAD_TEST_DURATION={duration}") + lines.append(f"export LOCUST_RUN_TIME={duration}") + return "\n".join(lines) + "\n" + + +def _format_load_flag_lines(params: LoadTestParams, *, headless: bool) -> str: + lines = build_locust_load_flag_lines( + params, + headless=headless, + shell_quote=_shell_quote, + ) + if not lines: + return "" + return " " + " \\\n ".join(lines) + " \\\n" + + +def _build_master_command( + request: LoadTestRunRequest, + app_config: AppConfig, + *, + master_private_ip: str, + worker_count: int, +) -> str: + install_dir = app_config.locust_install_dir + locustfile_path = _locustfile_remote_path(app_config) + bin_path = locust_bin(install_dir) + auth_export = "" + auth_file_bootstrap = "" + if request.auth_header: + auth_export = ( + f"export LOAD_TEST_AUTH_HEADER={_shell_quote(request.auth_header)}\n" + ) + auth_file_bootstrap = _auth_file_bootstrap_lines(request.auth_header) + load_flags = _format_load_flag_lines(request.load_params, headless=True) + load_env = _load_params_env_exports(request.load_params) + extra_exports = _extra_env_exports(request.extra_env) + return f""" +set -euo pipefail +export PYTHONPATH={install_dir!r} +export LOAD_TEST_PATHS={_shell_quote(request.paths)} +{_scenario_export(request.scenario)}{_operator_scenario_env_exports()}{load_env}{auth_export}{extra_exports}{auth_file_bootstrap}pkill -x locust 2>/dev/null || true +{bin_path} -f {locustfile_path} \\ + --master \\ + --master-bind-host {_shell_quote(master_private_ip)} \\ + --expect-workers {worker_count} \\ +{load_flags} --host {_shell_quote(normalize_locust_host(request.target_url))} +""" + + +def _build_master_ui_command( + request: LoadTestUiRequest, + app_config: AppConfig, + *, + master_private_ip: str, + worker_count: int, +) -> str: + install_dir = app_config.locust_install_dir + locustfile_path = _locustfile_remote_path(app_config) + bin_path = locust_bin(install_dir) + auth_export = "" + auth_file_bootstrap = "" + if request.auth_header: + auth_export = ( + f"export LOAD_TEST_AUTH_HEADER={_shell_quote(request.auth_header)}\n" + ) + auth_file_bootstrap = _auth_file_bootstrap_lines(request.auth_header) + load_flags = _format_load_flag_lines(request.load_params, headless=False) + load_env = _load_params_env_exports(request.load_params) + extra_exports = _extra_env_exports(request.extra_env) + return f""" +set -euo pipefail +export PYTHONPATH={install_dir!r} +export LOAD_TEST_PATHS={_shell_quote(request.paths)} +{_scenario_export(request.scenario)}{_operator_scenario_env_exports()}{load_env}{auth_export}{extra_exports}{auth_file_bootstrap}pkill -x locust 2>/dev/null || true +nohup {bin_path} -f {locustfile_path} \\ + --master \\ + --master-bind-host {_shell_quote(master_private_ip)} \\ + --expect-workers {worker_count} \\ + --class-picker \\ + --web-host 127.0.0.1 \\ +{load_flags} --host {_shell_quote(normalize_locust_host(request.target_url))} \\ + > /var/log/locust-master.log 2>&1 & +""" + + +def _send_command( + ssm_client, + *, + instance_ids: Sequence[str], + commands: Sequence[str], + comment: str, +) -> str: + response = ssm_client.send_command( + InstanceIds=list(instance_ids), + DocumentName=_SSM_DOCUMENT, + Comment=comment, + Parameters={"commands": list(commands)}, + TimeoutSeconds=_COMMAND_TIMEOUT_SECONDS, + ) + return response["Command"]["CommandId"] + + +def _wait_for_command( + ssm_client, + *, + command_id: str, + instance_id: str, +) -> str: + deadline = time.time() + _COMMAND_TIMEOUT_SECONDS + while time.time() < deadline: + try: + response = ssm_client.get_command_invocation( + CommandId=command_id, + InstanceId=instance_id, + ) + except ClientError as exc: + error_code = exc.response.get("Error", {}).get("Code", "") + if error_code != "InvocationDoesNotExist": + raise + time.sleep(_POLL_INTERVAL_SECONDS) + continue + status = response["Status"] + if status in {"Success", "Cancelled", "TimedOut", "Failed"}: + stdout = response.get("StandardOutputContent", "") + stderr = response.get("StandardErrorContent", "") + if status != "Success": + msg = f"SSM command {command_id} on {instance_id} ended with {status}" + if stderr: + msg = f"{msg}\n{stderr}" + raise RuntimeError(msg) + return stdout + time.sleep(_POLL_INTERVAL_SECONDS) + + msg = f"Timed out waiting for SSM command {command_id} on {instance_id}" + raise TimeoutError(msg) + + +def _sync_locust_sources( + ssm_client, + *, + instance_ids: Sequence[str], + app_config: AppConfig, +) -> None: + if not instance_ids: + return + script = _build_sync_locust_sources_script(app_config) + command_id = _send_command( + ssm_client, + instance_ids=instance_ids, + commands=[_wrap_bash_script(script)], + comment="Sync Locust source files", + ) + for instance_id in instance_ids: + _wait_for_command( + ssm_client, + command_id=command_id, + instance_id=instance_id, + ) + + +def _bootstrap_locust( + ssm_client, + *, + instance_ids: Sequence[str], + app_config: AppConfig, +) -> None: + if not instance_ids: + return + script = _bootstrap_locust_script(app_config.locust_install_dir) + command_id = _send_command( + ssm_client, + instance_ids=instance_ids, + commands=[_wrap_bash_script(script)], + comment="Bootstrap Locust if missing", + ) + for instance_id in instance_ids: + _wait_for_command( + ssm_client, + command_id=command_id, + instance_id=instance_id, + ) + + +def _verify_master_web_ui( + ssm_client, + *, + master_instance_id: str, +) -> None: + command_id = _send_command( + ssm_client, + instance_ids=[master_instance_id], + commands=[_wrap_bash_script(_verify_master_web_ui_script())], + comment="Verify Locust web UI port", + ) + _wait_for_command( + ssm_client, + command_id=command_id, + instance_id=master_instance_id, + ) + + +def _start_workers( + ssm_client, + *, + worker_instance_ids: Sequence[str], + master_private_ip: str, + app_config: AppConfig, + runtime: WorkerRuntimeEnv, +) -> None: + if not worker_instance_ids: + return + worker_command_id = _send_command( + ssm_client, + instance_ids=worker_instance_ids, + commands=[ + _wrap_bash_script( + _build_worker_command( + app_config, + master_private_ip, + runtime, + ) + ) + ], + comment="Start Locust workers", + ) + for worker_id in worker_instance_ids: + _wait_for_command( + ssm_client, + command_id=worker_command_id, + instance_id=worker_id, + ) + + +def run_load_test(request: LoadTestRunRequest, app_config: AppConfig) -> str: + """Start workers, run a headless Locust test on the master, and return stdout.""" + outputs = load_stack_outputs(request.stack, app_config.pulumi_project_dir) + master_instance_id = str(outputs["master_instance_id"]) + master_private_ip = str(outputs["master_private_ip"]) + worker_instance_ids = [ + str(instance_id) for instance_id in outputs["worker_instance_ids"] + ] + worker_count = len(worker_instance_ids) + + ssm_client = _create_ssm_client(request.profile, request.region) + all_instance_ids = [master_instance_id, *worker_instance_ids] + _sync_locust_sources( + ssm_client, instance_ids=all_instance_ids, app_config=app_config + ) + _bootstrap_locust(ssm_client, instance_ids=all_instance_ids, app_config=app_config) + _start_workers( + ssm_client, + worker_instance_ids=worker_instance_ids, + master_private_ip=master_private_ip, + app_config=app_config, + runtime=_worker_runtime_env( + scenario=request.scenario, + paths=request.paths, + auth_header=request.auth_header, + extra_env=request.extra_env, + ), + ) + + master_command_id = _send_command( + ssm_client, + instance_ids=[master_instance_id], + commands=[ + _wrap_bash_script( + _build_master_command( + request, + app_config, + master_private_ip=master_private_ip, + worker_count=worker_count, + ) + ) + ], + comment="Run Locust headless master", + ) + return _wait_for_command( + ssm_client, + command_id=master_command_id, + instance_id=master_instance_id, + ) + + +def start_locust_ui(request: LoadTestUiRequest, app_config: AppConfig) -> str: + """Start workers and a Locust master with the web UI enabled.""" + outputs = load_stack_outputs(request.stack, app_config.pulumi_project_dir) + master_instance_id = str(outputs["master_instance_id"]) + master_private_ip = str(outputs["master_private_ip"]) + worker_instance_ids = [ + str(instance_id) for instance_id in outputs["worker_instance_ids"] + ] + worker_count = len(worker_instance_ids) + + ssm_client = _create_ssm_client(request.profile, request.region) + all_instance_ids = [master_instance_id, *worker_instance_ids] + _sync_locust_sources( + ssm_client, instance_ids=all_instance_ids, app_config=app_config + ) + _bootstrap_locust(ssm_client, instance_ids=all_instance_ids, app_config=app_config) + _start_workers( + ssm_client, + worker_instance_ids=worker_instance_ids, + master_private_ip=master_private_ip, + app_config=app_config, + runtime=_worker_runtime_env( + scenario=request.scenario, + paths=request.paths, + auth_header=request.auth_header, + extra_env=request.extra_env, + ), + ) + + master_command_id = _send_command( + ssm_client, + instance_ids=[master_instance_id], + commands=[ + _wrap_bash_script( + _build_master_ui_command( + request, + app_config, + master_private_ip=master_private_ip, + worker_count=worker_count, + ) + ) + ], + comment="Start Locust master web UI", + ) + stdout = _wait_for_command( + ssm_client, + command_id=master_command_id, + instance_id=master_instance_id, + ) + _verify_master_web_ui(ssm_client, master_instance_id=master_instance_id) + return stdout + + +def reset_cluster_locust( + *, + stack: str, + profile: str | None, + region: str | None, + app_config: AppConfig, +) -> None: + """Stop Locust on all cluster instances and free RPC/web UI ports.""" + outputs = load_stack_outputs(stack, app_config.pulumi_project_dir) + instance_ids = [str(outputs["master_instance_id"])] + instance_ids.extend( + str(instance_id) for instance_id in outputs["worker_instance_ids"] + ) + + ssm_client = _create_ssm_client(profile, region) + command_id = _send_command( + ssm_client, + instance_ids=instance_ids, + commands=[_wrap_bash_script(_reset_locust_cluster_script())], + comment="Reset Locust processes on cluster", + ) + for instance_id in instance_ids: + _wait_for_command( + ssm_client, + command_id=command_id, + instance_id=instance_id, + ) + + +def get_cluster_status( + *, + stack: str, + profile: str | None, + region: str | None, + app_config: AppConfig, +) -> dict[str, object]: + """Return stack outputs and SSM ping status for cluster instances.""" + outputs = load_stack_outputs(stack, app_config.pulumi_project_dir) + instance_ids = [str(outputs["master_instance_id"])] + instance_ids.extend( + str(instance_id) for instance_id in outputs["worker_instance_ids"] + ) + + ssm_client = _create_ssm_client(profile, region) + response = ssm_client.describe_instance_information( + Filters=[{"Key": "InstanceIds", "Values": instance_ids}], + ) + ping_status = dict.fromkeys(instance_ids, "Unknown") + for entry in response.get("InstanceInformationList", []): + ping_status[entry["InstanceId"]] = entry.get("PingStatus", "Unknown") + + return { + "outputs": outputs, + "ssm_ping_status": ping_status, + } + + +def build_ui_port_forward_command( + *, + master_instance_id: str, + region: str, + profile: str | None, + local_port: int = 8089, +) -> str: + """Return the AWS CLI command to port-forward the Locust web UI.""" + profile_flag = f"--profile {profile} " if profile else "" + return ( + f"aws {profile_flag}ssm start-session " + f"--target {master_instance_id} " + f"--region {region} " + f"--document-name AWS-StartPortForwardingSession " + f"--parameters localPortNumber={local_port},portNumber=8089" + ) diff --git a/packages/python/ess-load-test/src/ess_load_test/infra/__init__.py b/packages/python/ess-load-test/src/ess_load_test/infra/__init__.py new file mode 100644 index 00000000..83789513 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/infra/__init__.py @@ -0,0 +1 @@ +"""Pulumi infrastructure for ess-load-test.""" diff --git a/packages/python/ess-load-test/src/ess_load_test/infra/__main__.py b/packages/python/ess-load-test/src/ess_load_test/infra/__main__.py new file mode 100644 index 00000000..ee581602 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/infra/__main__.py @@ -0,0 +1,5 @@ +"""Pulumi entrypoint for the generic Locust load test EC2 cluster.""" + +from ess_load_test.infra.stack import deploy_cluster + +deploy_cluster() diff --git a/packages/python/ess-load-test/src/ess_load_test/infra/config.py b/packages/python/ess-load-test/src/ess_load_test/infra/config.py new file mode 100644 index 00000000..a1a1c478 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/infra/config.py @@ -0,0 +1,52 @@ +"""Pulumi config loading for the Locust load test stack.""" + +from dataclasses import dataclass + +import pulumi + +_DEFAULT_ENVIRONMENT = "dev" +_DEFAULT_INSTANCE_TYPE = "t3.large" +_DEFAULT_MASTER_INSTANCE_TYPE = "t3.small" +_DEFAULT_WORKER_COUNT = 2 + + +@dataclass(frozen=True) +class LoadTestInfraConfig: + """Typed configuration for the load test infrastructure stack.""" + + environment: str + instance_type: str + master_instance_type: str + worker_count: int + use_default_vpc: bool + vpc_id_override: str | None + subnet_ids_override: tuple[str, ...] + + +def load_config(config_namespace: str) -> LoadTestInfraConfig: + """Load stack configuration from Pulumi config.""" + cfg = pulumi.Config(config_namespace) + + raw_subnet_ids = cfg.get("subnetIds") + subnet_ids_override = tuple( + subnet_id.strip() + for subnet_id in (raw_subnet_ids or "").split(",") + if subnet_id.strip() + ) + + worker_count = cfg.get_int("workerCount") + if worker_count is None: + worker_count = _DEFAULT_WORKER_COUNT + + return LoadTestInfraConfig( + environment=cfg.get("environment") or _DEFAULT_ENVIRONMENT, + instance_type=cfg.get("instanceType") or _DEFAULT_INSTANCE_TYPE, + master_instance_type=cfg.get("masterInstanceType") + or _DEFAULT_MASTER_INSTANCE_TYPE, + worker_count=worker_count, + use_default_vpc=cfg.get_bool("useDefaultVpc") + if cfg.get("useDefaultVpc") is not None + else True, + vpc_id_override=cfg.get("vpcId"), + subnet_ids_override=subnet_ids_override, + ) diff --git a/packages/python/ess-load-test/src/ess_load_test/infra/constants.py b/packages/python/ess-load-test/src/ess_load_test/infra/constants.py new file mode 100644 index 00000000..536cf555 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/infra/constants.py @@ -0,0 +1,47 @@ +"""Shared constants for ess-load-test Pulumi stacks.""" + +UBUNTU_AMI_SSM_PARAMETER = ( + "/aws/service/canonical/ubuntu/server/22.04/stable/current/amd64/hvm/ebs-gp2/ami-id" +) + + +def locustfile_remote_path(install_dir: str) -> str: + return f"{install_dir}/locustfile.py" + + +def locust_venv_dir(install_dir: str) -> str: + return f"{install_dir}/venv" + + +def locust_bin(install_dir: str) -> str: + return f"{locust_venv_dir(install_dir)}/bin/locust" + + +def locust_pip(install_dir: str) -> str: + return f"{locust_venv_dir(install_dir)}/bin/pip" + + +def base_tags(component_name: str, project_tag: str | None = None) -> dict[str, str]: + return { + "Project": project_tag or component_name, + "ManagedBy": "pulumi", + "Component": component_name, + } + + +def get_tags( + component_name: str, + environment: str, + extra: dict[str, str] | None = None, + *, + project_tag: str | None = None, +) -> dict[str, str]: + """Merge base tags with environment and optional extra tags. + + ``project_tag`` sets the ``Project`` tag and defaults to + ``component_name``. + """ + merged = {**base_tags(component_name, project_tag), "Environment": environment} + if extra: + merged.update(extra) + return merged diff --git a/packages/python/ess-load-test/src/ess_load_test/infra/instances.py b/packages/python/ess-load-test/src/ess_load_test/infra/instances.py new file mode 100644 index 00000000..cba257c9 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/infra/instances.py @@ -0,0 +1,214 @@ +"""EC2 instance provisioning for the Locust load test cluster.""" + +from dataclasses import dataclass + +import pulumi +import pulumi_aws as aws + +from ess_load_test.app_config import LocustSyncFile +from ess_load_test.infra.config import LoadTestInfraConfig +from ess_load_test.infra.constants import ( + UBUNTU_AMI_SSM_PARAMETER, + get_tags, + locust_pip, + locust_venv_dir, +) +from ess_load_test.infra.network import NetworkPlacement + + +@dataclass +class InstanceOutputs: + """Outputs from the instances module.""" + + master_instance_id: pulumi.Output[str] + master_private_ip: pulumi.Output[str] + worker_instance_ids: list[pulumi.Output[str]] + + +def controller_hostname(resource_prefix: str) -> str: + """Return the EC2 Name tag and OS hostname for the Locust coordinator.""" + return f"{resource_prefix}-locust-controller" + + +def worker_hostname(resource_prefix: str, index: int) -> str: + """Return the EC2 Name tag and OS hostname for a Locust worker.""" + return f"{resource_prefix}-locust-worker-{index}" + + +EC2_USER_DATA_MAX_BYTES = 16 * 1024 + + +def _build_user_data( + hostname: str, + *, + locust_install_dir: str, + cloud_init_files: tuple[LocustSyncFile, ...], +) -> str: + """Build cloud-init user data that sets hostname and installs Locust. + + Locust scenario sources are synced via SSM before each run (see cluster + runner); they are not embedded here because EC2 user_data is capped at + 16 KiB and a typical scenario bundle exceeds that limit. + """ + del cloud_init_files + venv_dir = locust_venv_dir(locust_install_dir) + pip_bin = locust_pip(locust_install_dir) + enable_ssm_agent = ( + "bash -lc 'systemctl enable snap.amazon-ssm-agent.amazon-ssm-agent.service " + "|| systemctl enable amazon-ssm-agent'" + ) + start_ssm_agent = ( + "bash -lc 'systemctl start snap.amazon-ssm-agent.amazon-ssm-agent.service " + "|| systemctl start amazon-ssm-agent'" + ) + return f"""#cloud-config +hostname: {hostname} +fqdn: {hostname}.local +manage_etc_hosts: true +preserve_hostname: false +package_update: true +packages: + - python3-pip + - python3-venv +runcmd: + - hostnamectl set-hostname {hostname} + - mkdir -p {locust_install_dir} + - python3 -m venv {venv_dir} + - {pip_bin} install -q 'locust>=2.32,<3' + - snap install amazon-ssm-agent --classic || apt-get install -y amazon-ssm-agent + - {enable_ssm_agent} + - {start_ssm_agent} +""" + + +def create_instance_profile( + resource_prefix: str, + *, + component_name: str, + environment: str, + project_tag: str | None = None, +) -> aws.iam.InstanceProfile: + """Create an IAM role and instance profile for SSM-managed EC2.""" + role = aws.iam.Role( + f"{resource_prefix}-ec2-role", + name=f"{resource_prefix}-ec2-role", + assume_role_policy="""{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "Service": "ec2.amazonaws.com" }, + "Action": "sts:AssumeRole" + } + ] +}""", + tags=get_tags( + component_name, + environment, + {"Name": f"{resource_prefix}-ec2-role"}, + project_tag=project_tag, + ), + ) + aws.iam.RolePolicyAttachment( + f"{resource_prefix}-ssm-core", + role=role.name, + policy_arn="arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore", + ) + profile = aws.iam.InstanceProfile( + f"{resource_prefix}-ec2-profile", + name=f"{resource_prefix}-ec2-profile", + role=role.name, + tags=get_tags( + component_name, + environment, + {"Name": f"{resource_prefix}-ec2-profile"}, + project_tag=project_tag, + ), + ) + return profile + + +def create_instances( # noqa: PLR0913 -- options after * are keyword-only + resource_prefix: str, + config: LoadTestInfraConfig, + placement: NetworkPlacement, + security_group_id: pulumi.Input[str], + instance_profile: aws.iam.InstanceProfile, + *, + component_name: str, + locust_install_dir: str, + cloud_init_files: tuple[LocustSyncFile, ...], + project_tag: str | None = None, +) -> InstanceOutputs: + """Create Locust coordinator and worker EC2 instances.""" + ami = aws.ssm.get_parameter(name=UBUNTU_AMI_SSM_PARAMETER) + environment = config.environment + + common_args = { + "ami": ami.value, + "subnet_id": placement.subnet_id, + "vpc_security_group_ids": [security_group_id], + "iam_instance_profile": instance_profile.name, + "associate_public_ip_address": True, + "user_data_replace_on_change": True, + "metadata_options": aws.ec2.InstanceMetadataOptionsArgs( + http_endpoint="enabled", + http_tokens="required", + ), + "root_block_device": aws.ec2.InstanceRootBlockDeviceArgs( + volume_size=20, + volume_type="gp3", + encrypted=True, + ), + } + + controller_name = controller_hostname(resource_prefix) + controller = aws.ec2.Instance( + f"{resource_prefix}-locust-controller", + instance_type=config.master_instance_type, + user_data=_build_user_data( + controller_name, + locust_install_dir=locust_install_dir, + cloud_init_files=cloud_init_files, + ), + tags=get_tags( + component_name, + environment, + { + "Name": controller_name, + "Role": "locust-controller", + }, + project_tag=project_tag, + ), + **common_args, + ) + + workers: list[aws.ec2.Instance] = [] + for index in range(config.worker_count): + worker_name = worker_hostname(resource_prefix, index) + worker = aws.ec2.Instance( + f"{resource_prefix}-locust-worker-{index}", + instance_type=config.instance_type, + user_data=_build_user_data( + worker_name, + locust_install_dir=locust_install_dir, + cloud_init_files=cloud_init_files, + ), + tags=get_tags( + component_name, + environment, + { + "Name": worker_name, + "Role": "locust-worker", + }, + project_tag=project_tag, + ), + **common_args, + ) + workers.append(worker) + + return InstanceOutputs( + master_instance_id=controller.id, + master_private_ip=controller.private_ip, + worker_instance_ids=[worker.id for worker in workers], + ) diff --git a/packages/python/ess-load-test/src/ess_load_test/infra/network.py b/packages/python/ess-load-test/src/ess_load_test/infra/network.py new file mode 100644 index 00000000..1aa5b03f --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/infra/network.py @@ -0,0 +1,170 @@ +"""Network discovery and security group resources.""" + +from dataclasses import dataclass + +import pulumi +import pulumi_aws as aws + +from ess_load_test.infra.config import LoadTestInfraConfig +from ess_load_test.infra.constants import get_tags + +_LOCUST_MASTER_PORT = 5557 +_LOCUST_STATS_PORT = 5558 + + +@dataclass(frozen=True) +class NetworkPlacement: + """Resolved VPC and subnet for EC2 instances.""" + + vpc_id: str + subnet_id: str + + +@dataclass +class NetworkOutputs: + """Outputs from the network module.""" + + placement: NetworkPlacement + security_group_id: pulumi.Output[str] + + +def resolve_network_placement( + config: LoadTestInfraConfig, + *, + config_namespace: str, +) -> NetworkPlacement: + """Resolve VPC and subnet IDs for instance placement.""" + has_vpc_override = bool(config.vpc_id_override) + has_subnet_override = bool(config.subnet_ids_override) + if has_vpc_override != has_subnet_override: + msg = ( + f"{config_namespace}:vpcId and {config_namespace}:subnetIds must both " + "be set when overriding network placement." + ) + raise ValueError(msg) + + if has_vpc_override and has_subnet_override: + return NetworkPlacement( + vpc_id=config.vpc_id_override, + subnet_id=config.subnet_ids_override[0], + ) + + if not config.use_default_vpc: + msg = ( + "useDefaultVpc is false but vpcId/subnetIds overrides are not set. " + f"Set {config_namespace}:vpcId and {config_namespace}:subnetIds." + ) + raise ValueError(msg) + + default_vpc = aws.ec2.get_vpc(default=True) + subnets = aws.ec2.get_subnets( + filters=[ + aws.ec2.GetSubnetsFilterArgs(name="vpc-id", values=[default_vpc.id]), + aws.ec2.GetSubnetsFilterArgs(name="default-for-az", values=["true"]), + ], + ) + if not subnets.ids: + msg = "No default-for-az subnets found in the default VPC." + raise ValueError(msg) + + return NetworkPlacement(vpc_id=default_vpc.id, subnet_id=subnets.ids[0]) + + +def build_locust_security_group_egress() -> list[aws.ec2.SecurityGroupEgressArgs]: + """Return egress rules for Locust load generator instances.""" + return [ + aws.ec2.SecurityGroupEgressArgs( + protocol="tcp", + from_port=80, + to_port=80, + cidr_blocks=["0.0.0.0/0"], + description="HTTP outbound for apt/bootstrap on instance launch", + ), + aws.ec2.SecurityGroupEgressArgs( + protocol="tcp", + from_port=443, + to_port=443, + cidr_blocks=["0.0.0.0/0"], + description="HTTPS outbound to apt mirrors, PyPI, and load test targets", + ), + aws.ec2.SecurityGroupEgressArgs( + protocol="udp", + from_port=53, + to_port=53, + cidr_blocks=["0.0.0.0/0"], + description="DNS (UDP) outbound", + ), + aws.ec2.SecurityGroupEgressArgs( + protocol="tcp", + from_port=53, + to_port=53, + cidr_blocks=["0.0.0.0/0"], + description="DNS (TCP) outbound", + ), + aws.ec2.SecurityGroupEgressArgs( + protocol="tcp", + from_port=_LOCUST_MASTER_PORT, + to_port=_LOCUST_STATS_PORT, + self=True, + description="Locust worker to master coordination and stats", + ), + ] + + +def build_locust_security_group_ingress() -> list[aws.ec2.SecurityGroupIngressArgs]: + """Return ingress rules for Locust master/worker coordination.""" + return [ + aws.ec2.SecurityGroupIngressArgs( + protocol="tcp", + from_port=_LOCUST_MASTER_PORT, + to_port=_LOCUST_STATS_PORT, + self=True, + description="Locust worker to master coordination and stats", + ), + ] + + +def has_public_internet_ingress( + ingress_rules: list[aws.ec2.SecurityGroupIngressArgs], +) -> bool: + """Return True if any ingress rule allows traffic from the public internet.""" + for rule in ingress_rules: + cidr_blocks = rule.cidr_blocks or [] + if "0.0.0.0/0" in cidr_blocks: + return True + ipv6_cidr_blocks = rule.ipv6_cidr_blocks or [] + if "::/0" in ipv6_cidr_blocks: + return True + return False + + +def create_network( + resource_prefix: str, + placement: NetworkPlacement, + *, + component_name: str, + environment: str, + project_tag: str | None = None, +) -> NetworkOutputs: + """Create the Locust cluster security group.""" + ingress_rules = build_locust_security_group_ingress() + security_group = aws.ec2.SecurityGroup( + f"{resource_prefix}-locust-sg", + name=f"{resource_prefix}-locust-sg", + description="Security group for Locust load test EC2 instances", + vpc_id=placement.vpc_id, + ingress=ingress_rules, + egress=build_locust_security_group_egress(), + tags=get_tags( + component_name, + environment, + {"Name": f"{resource_prefix}-locust-sg"}, + project_tag=project_tag, + ), + opts=pulumi.ResourceOptions(delete_before_replace=False), + ) + + return NetworkOutputs( + placement=placement, + security_group_id=security_group.id, + ) diff --git a/packages/python/ess-load-test/src/ess_load_test/infra/stack.py b/packages/python/ess-load-test/src/ess_load_test/infra/stack.py new file mode 100644 index 00000000..7ed1f63c --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/infra/stack.py @@ -0,0 +1,86 @@ +"""Composable Locust load-test cluster deployment.""" + +from pathlib import Path + +import pulumi +import pulumi_aws as aws +from dotenv import load_dotenv + +from ess_load_test.app_config import LocustBundle, LocustSyncFile, default_app_config +from ess_load_test.infra.config import load_config +from ess_load_test.infra.instances import create_instance_profile, create_instances +from ess_load_test.infra.network import create_network, resolve_network_placement + + +def deploy_cluster( # noqa: PLR0913 -- all params are keyword-only with defaults + *, + locust_install_dir: str | None = None, + component_name: str | None = None, + pulumi_config_namespace: str | None = None, + cloud_init_bundle: LocustBundle | None = None, + pulumi_project_dir: Path | None = None, + project_tag: str | None = None, +) -> None: + """Deploy the Locust EC2 cluster and export Pulumi outputs. + + Shells call this from ``infra/__main__.py`` with organization-specific + install paths, tags, and Locust source bundles. ``project_tag`` sets the + ``Project`` tag on every resource and defaults to the component name. + """ + defaults = default_app_config() + resolved_install_dir = locust_install_dir or defaults.locust_install_dir + resolved_component = component_name or defaults.component_name + resolved_namespace = pulumi_config_namespace or defaults.config_namespace + resolved_bundle = cloud_init_bundle or defaults.locust_bundle + resolved_project_dir = pulumi_project_dir or defaults.pulumi_project_dir + + load_dotenv(resolved_project_dir / ".env") + + config = load_config(resolved_namespace) + resource_prefix = f"load-test-{config.environment}" + + current_region = aws.get_region() + placement = resolve_network_placement( + config, + config_namespace=resolved_namespace, + ) + network = create_network( + resource_prefix, + placement, + component_name=resolved_component, + environment=config.environment, + project_tag=project_tag, + ) + instance_profile = create_instance_profile( + resource_prefix, + component_name=resolved_component, + environment=config.environment, + project_tag=project_tag, + ) + instances = create_instances( + resource_prefix=resource_prefix, + config=config, + placement=placement, + security_group_id=network.security_group_id, + instance_profile=instance_profile, + component_name=resolved_component, + locust_install_dir=resolved_install_dir, + cloud_init_files=cloud_init_files_from_bundle(resolved_bundle), + project_tag=project_tag, + ) + + pulumi.export("master_instance_id", instances.master_instance_id) + pulumi.export("master_private_ip", instances.master_private_ip) + pulumi.export("worker_instance_ids", instances.worker_instance_ids) + pulumi.export("security_group_id", network.security_group_id) + pulumi.export("region", current_region.region) + pulumi.export("worker_count", config.worker_count) + + +def cloud_init_files_from_bundle(bundle: LocustBundle) -> tuple[LocustSyncFile, ...]: + """Return sync files for cloud-init (paths must exist on disk).""" + for sync_file in bundle.sync_files: + if not sync_file.local_path.is_file(): + msg = f"Cloud-init source missing: {sync_file.local_path}" + raise FileNotFoundError(msg) + return bundle.sync_files diff --git a/packages/python/ess-load-test/src/ess_load_test/locust/__init__.py b/packages/python/ess-load-test/src/ess_load_test/locust/__init__.py new file mode 100644 index 00000000..a631c46e --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/locust/__init__.py @@ -0,0 +1 @@ +"""Locust scenario modules for ess-load-test.""" diff --git a/packages/python/ess-load-test/src/ess_load_test/locust/locustfile.py b/packages/python/ess-load-test/src/ess_load_test/locust/locustfile.py new file mode 100644 index 00000000..1d508a43 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/locust/locustfile.py @@ -0,0 +1,42 @@ +"""Locust entrypoint — default scenario only. + +Deployment layout: the cluster runner syncs this directory to EC2 as flat files +under the configured install dir (``locustfile.py``, ``locust_runtime.py``, …) +with ``PYTHONPATH`` set there. Imports therefore use bare module names on workers. + +In the monorepo the same sources live under ``ess_load_test.locust.*``. +Each file uses ``try/except ImportError`` so pylint, tests, and EC2 all resolve +imports without inline ``pylint: disable=import-error`` suppressions. +""" + +import os + +from locust import events + +try: + from locust_runtime import normalize_scenario +except ImportError: + from ess_load_test.locust.runtime import normalize_scenario + +try: + from scenario_default import LoadTestUser +except ImportError: + from ess_load_test.locust.scenario_default import LoadTestUser + +_SCENARIO = normalize_scenario(os.environ.get("LOAD_TEST_SCENARIO")) + +_LOCUST_RUN_PROFILES = ("smoke", "default-run") + + +@events.init.add_listener +def on_locust_init(environment, **_kwargs) -> None: + """Suggest run labels in the Locust web UI Profile field.""" + if environment.web_ui is None: + return + + environment.web_ui.template_args["all_profiles"] = list(_LOCUST_RUN_PROFILES) + + +# Locust discovers HttpUser subclasses imported here; the tuple references +# them so pylint does not report the imports as unused (F401). +_LOCUST_USER_CLASSES = (LoadTestUser,) diff --git a/packages/python/ess-load-test/src/ess_load_test/locust/runtime.py b/packages/python/ess-load-test/src/ess_load_test/locust/runtime.py new file mode 100644 index 00000000..2f8e9b81 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/locust/runtime.py @@ -0,0 +1,188 @@ +"""Locust helpers deployed to EC2 (stdlib only — no locust import). + +On EC2 this module is written to ``locust_runtime.py`` beside ``locustfile.py``. +Scenario modules import ``locust_runtime`` there; in the monorepo they import +``ess_load_test.locust.runtime`` via ``try/except ImportError`` shims. +""" + +import os +from http import HTTPStatus +from typing import Protocol + +_DEFAULT_PATHS = ("/",) +_AUTH_HEADER_FILE = "/tmp/load-test-auth-header.txt" # nosec B108 — EC2 worker sync path +_REDIRECT_STATUS_CODES = frozenset({301, 302, 307, 308}) + +VALID_SCENARIOS = frozenset({"default"}) +DEFAULT_SCENARIO = "default" +UI_DEFAULT_SCENARIO = "default" + +_SCENARIO_DEFAULT_PROFILES: dict[str, str] = { + "default": "smoke", + "pages": "pages-run", + "bff": "bff-run", + "api": "api-run", + "mixed": "mixed-run", +} + +LOCUST_RUN_PROFILES = ( + "smoke", + "pages-run", + "bff-run", + "api-run", + "mixed-run", +) + + +class _ResponseLike(Protocol): + status_code: int + + def failure(self, message: str) -> None: ... + + def success(self) -> None: ... + + +class InvalidScenarioError(ValueError): + """Raised when LOAD_TEST_SCENARIO is not a supported value.""" + + +def normalize_scenario( + raw_scenario: str | None, + *, + valid_scenarios: frozenset[str] | None = None, +) -> str: + """Return a supported scenario name from env or CLI input.""" + scenarios = valid_scenarios or VALID_SCENARIOS + if not raw_scenario or not raw_scenario.strip(): + return DEFAULT_SCENARIO + scenario = raw_scenario.strip().lower() + if scenario not in scenarios: + options = ", ".join(sorted(scenarios)) + msg = f"Unsupported LOAD_TEST_SCENARIO {scenario!r}. Expected one of: {options}" + raise InvalidScenarioError(msg) + return scenario + + +def mark_response_failure( + response: _ResponseLike, + *, + strict: bool = False, + accept_statuses: frozenset[int] | None = None, +) -> None: + """Mark a Locust response as failed for 5xx, or 4xx when strict. + + Locust treats 4xx as failures on context exit unless ``response.success()`` + was called. Pass ``accept_statuses`` for expected non-2xx outcomes (e.g. + validation probes) so they are not counted as failures. + """ + status_code = response.status_code + if status_code >= HTTPStatus.INTERNAL_SERVER_ERROR: + response.failure(f"status {status_code}") + return + if accept_statuses and status_code in accept_statuses: + response.success() + return + if strict and status_code >= HTTPStatus.BAD_REQUEST: + response.failure(f"status {status_code}") + + +def parse_load_test_paths(raw_paths: str | None) -> tuple[str, ...]: + """Parse comma-separated HTTP paths for load test tasks.""" + if not raw_paths: + return _DEFAULT_PATHS + paths = tuple(path.strip() for path in raw_paths.split(",") if path.strip()) + return paths or _DEFAULT_PATHS + + +def normalize_locust_host(target_url: str) -> str: + """Strip trailing slashes from the Locust --host base URL. + + Next.js returns 308 when ``--host`` ends with ``/`` and paths start with + ``/api/...``, which breaks POST requests (auth cookies never reach the route). + """ + return target_url.strip().rstrip("/") + + +def default_profile_for_scenario(scenario: str) -> str: + """Return the suggested Locust UI profile label for a load-test scenario.""" + return _SCENARIO_DEFAULT_PROFILES.get(scenario, "smoke") + + +def read_load_test_users() -> int | None: + """Return peak user count from LOAD_TEST_USERS or LOCUST_USERS.""" + for env_name in ("LOAD_TEST_USERS", "LOCUST_USERS"): + raw_value = os.environ.get(env_name) + if raw_value is not None and raw_value.strip(): + return int(raw_value.strip()) + return None + + +def read_load_test_spawn_rate() -> float | None: + """Return spawn rate from LOAD_TEST_SPAWN_RATE or LOCUST_SPAWN_RATE.""" + for env_name in ("LOAD_TEST_SPAWN_RATE", "LOCUST_SPAWN_RATE"): + raw_value = os.environ.get(env_name) + if raw_value is not None and raw_value.strip(): + return float(raw_value.strip()) + return None + + +def read_load_test_duration() -> str | None: + """Return run duration string from LOAD_TEST_DURATION or LOCUST_RUN_TIME.""" + for env_name in ("LOAD_TEST_DURATION", "LOCUST_RUN_TIME"): + raw_value = os.environ.get(env_name) + if raw_value is not None and raw_value.strip(): + return raw_value.strip() + return None + + +def read_auth_header() -> str | None: + """Return the Locust auth header from a synced file or process env.""" + file_path = os.environ.get("LOAD_TEST_AUTH_HEADER_FILE", _AUTH_HEADER_FILE) + try: + with open(file_path, encoding="utf-8") as auth_file: + from_file = auth_file.read().strip() + except OSError: + from_file = "" + if from_file: + return from_file + raw = os.environ.get("LOAD_TEST_AUTH_HEADER") + if raw and raw.strip(): + return raw.strip() + return None + + +def apply_auth_header(headers: dict[str, str], raw_auth_header: str | None) -> None: + """Apply an optional auth header value to request headers.""" + if not raw_auth_header: + return + if ":" in raw_auth_header: + header_name, header_value = raw_auth_header.split(":", 1) + headers[header_name.strip()] = header_value.strip() + return + headers["Authorization"] = raw_auth_header.strip() + + +def apply_auth_to_client(client, raw_auth_header: str | None) -> None: + """Apply browser cookie or bearer auth to a Locust requests session.""" + if not raw_auth_header: + return + if ":" in raw_auth_header: + header_name, header_value = raw_auth_header.split(":", 1) + name = header_name.strip() + value = header_value.strip() + client.headers[name] = value + if name.lower() == "cookie": + client.cookies.clear() + for raw_cookie_pair in value.split(";"): + cookie_pair = raw_cookie_pair.strip() + if "=" not in cookie_pair: + continue + cookie_name, cookie_value = cookie_pair.split("=", 1) + client.cookies.set(cookie_name.strip(), cookie_value.strip()) + return + client.headers["Authorization"] = raw_auth_header.strip() + + +def is_auth_redirect(status_code: int) -> bool: + """Return True when an unauthenticated API call was redirected to sign-in.""" + return status_code in _REDIRECT_STATUS_CODES diff --git a/packages/python/ess-load-test/src/ess_load_test/locust/scenario_default.py b/packages/python/ess-load-test/src/ess_load_test/locust/scenario_default.py new file mode 100644 index 00000000..0210c62e --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/locust/scenario_default.py @@ -0,0 +1,31 @@ +"""Default Locust user that hits configurable HTTP paths.""" + +import os +from http import HTTPStatus + +from locust import HttpUser, between, task + +try: + from locust_runtime import apply_auth_header, parse_load_test_paths +except ImportError: + from ess_load_test.locust.runtime import apply_auth_header, parse_load_test_paths + + +class LoadTestUser(HttpUser): + """Simulate users hitting one or more HTTP paths on the target host.""" + + wait_time = between(0.5, 1.5) + + def on_start(self) -> None: + apply_auth_header( + self.client.headers, + os.environ.get("LOAD_TEST_AUTH_HEADER"), + ) + + @task + def request_paths(self) -> None: + paths = parse_load_test_paths(os.environ.get("LOAD_TEST_PATHS")) + for path in paths: + with self.client.get(path, catch_response=True, name=path) as response: + if response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR: + response.failure(f"status {response.status_code}") diff --git a/packages/python/ess-load-test/src/ess_load_test/ui/__init__.py b/packages/python/ess-load-test/src/ess_load_test/ui/__init__.py new file mode 100644 index 00000000..68515f09 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/ui/__init__.py @@ -0,0 +1,35 @@ +"""Local Locust web UI workflow helpers.""" + +from ess_load_test.ui.local_port import ( + LocalPortExpectation, + check_local_port, + cleanup_stale_port_forward, + is_local_port_listening, + kill_stale_ssm_port_forward, +) +from ess_load_test.ui.workflow import ( + UiWorkflowConfig, + UiWorkflowError, + build_port_forward_argv, + open_locust_ui, + print_cluster_status, + resolve_auth_header, + run_port_forward, + run_preflight, +) + +__all__ = [ + "LocalPortExpectation", + "UiWorkflowConfig", + "UiWorkflowError", + "build_port_forward_argv", + "check_local_port", + "cleanup_stale_port_forward", + "is_local_port_listening", + "kill_stale_ssm_port_forward", + "open_locust_ui", + "print_cluster_status", + "resolve_auth_header", + "run_port_forward", + "run_preflight", +] diff --git a/packages/python/ess-load-test/src/ess_load_test/ui/local_port.py b/packages/python/ess-load-test/src/ess_load_test/ui/local_port.py new file mode 100644 index 00000000..c00864c9 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/ui/local_port.py @@ -0,0 +1,61 @@ +"""Local port-forward helpers for SSM tunnel troubleshooting.""" + +from __future__ import annotations + +import subprocess +from enum import Enum + + +class LocalPortExpectation(str, Enum): + """Expected state of a local TCP port.""" + + FREE = "free" + LISTENING = "listening" + + +def is_local_port_listening(port: int) -> bool: + """Return True when a process is listening on *port*.""" + result = subprocess.run( + ["lsof", "-i", f":{port}", "-sTCP:LISTEN", "-P", "-n"], + capture_output=True, + text=True, + check=False, + ) + return result.returncode == 0 + + +def kill_stale_ssm_port_forward(port: int) -> int: + """Stop local SSM port-forward processes for *port*.""" + patterns = ( + rf"session-manager-plugin.*{port}", + rf"ssm start-session.*localPortNumber={port}", + ) + killed = 0 + for pattern in patterns: + result = subprocess.run( + ["pkill", "-f", pattern], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + killed += 1 + return killed + + +def check_local_port(port: int, *, expect: LocalPortExpectation) -> None: + """Raise ``RuntimeError`` when the port does not match *expect*.""" + listening = is_local_port_listening(port) + if expect == LocalPortExpectation.FREE and listening: + msg = f"Port {port} is in use (expected free)." + raise RuntimeError(msg) + if expect == LocalPortExpectation.LISTENING and not listening: + msg = f"Port {port} is free (expected listening)." + raise RuntimeError(msg) + + +def cleanup_stale_port_forward(port: int) -> int: + """Kill stale SSM forwards and verify *port* is free.""" + killed = kill_stale_ssm_port_forward(port) + check_local_port(port, expect=LocalPortExpectation.FREE) + return killed diff --git a/packages/python/ess-load-test/src/ess_load_test/ui/workflow.py b/packages/python/ess-load-test/src/ess_load_test/ui/workflow.py new file mode 100644 index 00000000..47777724 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/ui/workflow.py @@ -0,0 +1,251 @@ +"""Orchestration for the Locust web UI workflow.""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass + +import boto3 +import click +from botocore.exceptions import ( + ClientError, + NoCredentialsError, + NoRegionError, + ProfileNotFound, +) + +from ess_load_test.app_config import AppConfig +from ess_load_test.cli.load_params import LoadTestParams +from ess_load_test.cluster.outputs import StackOutputError, load_stack_outputs +from ess_load_test.cluster.runner import ( + LoadTestUiRequest, + build_ui_port_forward_command, + get_cluster_status, + start_locust_ui, +) + + +class UiWorkflowError(RuntimeError): + """Raised when the Locust UI workflow cannot proceed.""" + + +@dataclass(frozen=True) +class UiWorkflowConfig: + """Parameters for the Locust web UI workflow.""" + + app_config: AppConfig + target_url: str + paths: str = "/" + scenario: str | None = None + stack: str | None = None + profile: str | None = None + region: str | None = None + local_port: int = 8089 + fetch_token: bool = True + auth_header: str | None = None + browser_auth: bool = False + headed: bool = False + browser_profile_dir: str | None = None + load_params: LoadTestParams | None = None + + @property + def resolved_load_params(self) -> LoadTestParams: + if self.load_params is not None: + return self.load_params + return LoadTestParams() + + @property + def resolved_scenario(self) -> str: + if self.scenario is not None: + return self.scenario + return self.app_config.ui_default_scenario + + @property + def resolved_stack(self) -> str: + return self.stack or self.app_config.default_stack + + @property + def resolved_profile(self) -> str | None: + return ( + self.profile + if self.profile is not None + else self.app_config.default_profile + ) + + @property + def resolved_region(self) -> str | None: + return self.region + + +def run_preflight(config: UiWorkflowConfig) -> None: + """Verify target URL and AWS credentials before starting Locust.""" + if not config.target_url.strip(): + msg = "Target URL is required (e.g. https://app.example.com)" + raise UiWorkflowError(msg) + + session_kwargs: dict[str, str] = {} + if config.resolved_profile: + session_kwargs["profile_name"] = config.resolved_profile + if config.resolved_region: + session_kwargs["region_name"] = config.resolved_region + + try: + boto3.Session(**session_kwargs).client("sts").get_caller_identity() + except (ClientError, NoCredentialsError, NoRegionError, ProfileNotFound) as exc: + msg = ( + f"AWS credentials failed for profile {config.resolved_profile!r}. " + "Refresh your AWS SSO session and retry." + ) + raise UiWorkflowError(msg) from exc + + +def resolve_auth_header( + explicit_header: str | None, + *, + fetch_token: bool, + resolve_token, +) -> str | None: + """Resolve the Locust auth header from explicit value, env, or OAuth.""" + if explicit_header: + return explicit_header + if not fetch_token: + return None + return resolve_token() + + +def open_locust_ui( + config: UiWorkflowConfig, + *, + resolve_token, +) -> tuple[dict[str, object], str]: + """Preflight, start Locust UI, return outputs and forward cmd.""" + run_preflight(config) + try: + outputs = load_stack_outputs( + config.resolved_stack, + config.app_config.pulumi_project_dir, + ) + except StackOutputError as exc: + raise UiWorkflowError(str(exc)) from exc + + resolved_region = config.resolved_region or str(outputs.get("region", "us-east-1")) + resolved_auth = resolve_auth_header( + config.auth_header, + fetch_token=config.fetch_token, + resolve_token=resolve_token, + ) + + try: + request = LoadTestUiRequest( + stack=config.resolved_stack, + target_url=config.target_url, + paths=config.paths, + scenario=config.resolved_scenario, + load_params=config.resolved_load_params, + auth_header=resolved_auth, + profile=config.resolved_profile, + region=resolved_region or None, + ) + start_locust_ui(request, config.app_config) + except (RuntimeError, TimeoutError) as exc: + raise UiWorkflowError(str(exc)) from exc + + forward_command = build_ui_port_forward_command( + master_instance_id=str(outputs["master_instance_id"]), + region=resolved_region, + profile=config.resolved_profile, + local_port=config.local_port, + ) + return outputs, forward_command + + +def print_cluster_status(config: UiWorkflowConfig) -> None: + """Print Pulumi outputs and SSM reachability.""" + try: + outputs = load_stack_outputs( + config.resolved_stack, + config.app_config.pulumi_project_dir, + ) + resolved_region = config.resolved_region or str(outputs.get("region", "")) + status = get_cluster_status( + stack=config.resolved_stack, + profile=config.resolved_profile, + region=resolved_region or None, + app_config=config.app_config, + ) + except StackOutputError as exc: + raise UiWorkflowError(str(exc)) from exc + + stack_outputs = status["outputs"] + click.echo(f"master_instance_id: {stack_outputs['master_instance_id']}") + click.echo(f"master_private_ip: {stack_outputs['master_private_ip']}") + click.echo(f"worker_instance_ids: {stack_outputs['worker_instance_ids']}") + click.echo(f"security_group_id: {stack_outputs['security_group_id']}") + click.echo(f"region: {stack_outputs['region']}") + click.echo("ssm_ping_status:") + for instance_id, ping_status in status["ssm_ping_status"].items(): + click.echo(f" {instance_id}: {ping_status}") + + +def build_port_forward_argv( + *, + master_instance_id: str, + region: str, + profile: str | None, + local_port: int, +) -> list[str]: + """Build argv for `aws ssm start-session` port-forward (no shell).""" + command = [ + "aws", + ] + if profile: + command.extend(["--profile", profile]) + command.extend( + [ + "ssm", + "start-session", + "--target", + master_instance_id, + "--region", + region, + "--document-name", + "AWS-StartPortForwardingSession", + "--parameters", + f"localPortNumber={local_port},portNumber=8089", + ] + ) + return command + + +def run_port_forward(config: UiWorkflowConfig, *, exec_forward: bool) -> str: + """Print or run the SSM port-forward command for the Locust web UI.""" + try: + outputs = load_stack_outputs( + config.resolved_stack, + config.app_config.pulumi_project_dir, + ) + except StackOutputError as exc: + raise UiWorkflowError(str(exc)) from exc + + resolved_region = config.resolved_region or str(outputs.get("region", "us-east-1")) + master_instance_id = str(outputs["master_instance_id"]) + forward_command = build_ui_port_forward_command( + master_instance_id=master_instance_id, + region=resolved_region, + profile=config.resolved_profile, + local_port=config.local_port, + ) + + if exec_forward: + argv = build_port_forward_argv( + master_instance_id=master_instance_id, + region=resolved_region, + profile=config.resolved_profile, + local_port=config.local_port, + ) + click.echo( + f"==> Port-forwarding Locust UI to http://localhost:{config.local_port}" + ) + subprocess.run(argv, check=True) + + return forward_command diff --git a/packages/python/ess-load-test/tests/test_auth_token.py b/packages/python/ess-load-test/tests/test_auth_token.py new file mode 100644 index 00000000..8b74b079 --- /dev/null +++ b/packages/python/ess-load-test/tests/test_auth_token.py @@ -0,0 +1,80 @@ +"""Tests for OAuth token helpers.""" + +import os +from unittest.mock import MagicMock, patch + +import pytest +from ess_load_test.app_config import OAuthSettings +from ess_load_test.auth.oauth import ( + OAuthCredentialsError, + fetch_bearer_token, + format_auth_header, + resolve_oauth_credentials, +) + +_OAUTH = OAuthSettings( + default_token_url="https://idp.example.com/oauth2/default/v1/token", + client_id_fallback_envs=("APP_SSO_CLIENT_ID",), + client_secret_fallback_envs=("APP_SSO_CLIENT_SECRET",), + token_url_fallback_envs=("APP_SSO_TOKEN_URL",), +) + + +def test_format_auth_header_adds_bearer_prefix() -> None: + assert format_auth_header("abc123") == "Authorization: Bearer abc123" + + +def test_format_auth_header_preserves_full_header() -> None: + assert format_auth_header("X-API-KEY: secret") == "X-API-KEY: secret" + + +def test_resolve_oauth_credentials_prefers_load_test_env() -> None: + env = { + "LOAD_TEST_OAUTH_CLIENT_ID": "load-id", + "LOAD_TEST_OAUTH_CLIENT_SECRET": "load-secret", + "APP_SSO_CLIENT_ID": "app-id", + "APP_SSO_CLIENT_SECRET": "app-secret", + "LOAD_TEST_OAUTH_TOKEN_URL": "https://idp.example.com/oauth2/v1/token", + } + with patch.dict(os.environ, env, clear=True): + client_id, client_secret, token_url = resolve_oauth_credentials(_OAUTH) + assert client_id == "load-id" + assert client_secret == "load-secret" + assert token_url == "https://idp.example.com/oauth2/v1/token" + + +def test_resolve_oauth_credentials_missing_raises() -> None: + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(OAuthCredentialsError): + resolve_oauth_credentials(_OAUTH) + + +def test_resolve_oauth_credentials_uses_default_token_url() -> None: + env = {"LOAD_TEST_OAUTH_CLIENT_ID": "id", "LOAD_TEST_OAUTH_CLIENT_SECRET": "s"} + with patch.dict(os.environ, env, clear=True): + _, _, token_url = resolve_oauth_credentials(_OAUTH) + assert token_url == "https://idp.example.com/oauth2/default/v1/token" + + +def test_resolve_oauth_credentials_requires_a_token_url() -> None: + env = {"LOAD_TEST_OAUTH_CLIENT_ID": "id", "LOAD_TEST_OAUTH_CLIENT_SECRET": "s"} + with patch.dict(os.environ, env, clear=True): + with pytest.raises(OAuthCredentialsError, match="LOAD_TEST_OAUTH_TOKEN_URL"): + resolve_oauth_credentials(OAuthSettings()) + + +@patch("ess_load_test.auth.oauth.TokenHelper") +def test_fetch_bearer_token_returns_access_token(mock_helper_cls: MagicMock) -> None: + mock_helper = MagicMock() + mock_helper.fetch_token.return_value = "token-value" + mock_helper_cls.return_value = mock_helper + + env = { + "LOAD_TEST_OAUTH_CLIENT_ID": "id", + "LOAD_TEST_OAUTH_CLIENT_SECRET": "secret", + } + with patch.dict(os.environ, env, clear=True): + assert fetch_bearer_token(_OAUTH) == "token-value" + + mock_helper_cls.assert_called_once() + mock_helper.fetch_token.assert_called_once() diff --git a/packages/python/ess-load-test/tests/test_browser_auth.py b/packages/python/ess-load-test/tests/test_browser_auth.py new file mode 100644 index 00000000..d7dbc987 --- /dev/null +++ b/packages/python/ess-load-test/tests/test_browser_auth.py @@ -0,0 +1,177 @@ +"""Tests for browser-based SSO auth.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from ess_load_test.auth.browser import ( + BrowserAuthError, + _authorization_from_request, + _cookies_to_header, + _has_auth_session, + fetch_auth_header_via_browser, +) + +_PROFILE_DIR = Path.home() / ".ess-load-test-test-profile" + + +def test_authorization_from_request_matches_target_host() -> None: + request = MagicMock() + request.url = "https://app.example.com/api/chat" + request.headers = {"authorization": "Bearer user-token"} + + header = _authorization_from_request(request, "app.example.com") + assert header == "Authorization: Bearer user-token" + + +def test_authorization_from_request_ignores_other_hosts() -> None: + request = MagicMock() + request.url = "https://other.example.com/api/chat" + request.headers = {"authorization": "Bearer user-token"} + + assert _authorization_from_request(request, "app.example.com") is None + + +def test_authorization_from_request_ignores_parent_domain() -> None: + request = MagicMock() + request.url = "https://example.com/api/chat" + request.headers = {"authorization": "Bearer user-token"} + + assert _authorization_from_request(request, "app.example.com") is None + + +def test_cookies_to_header_formats_cookie_header() -> None: + page = MagicMock() + page.context.cookies.return_value = [ + {"name": "session", "value": "abc"}, + {"name": "csrf", "value": "xyz"}, + ] + + header = _cookies_to_header(page, "https://app.example.com") + assert header == "Cookie: session=abc; csrf=xyz" + + +def test_has_auth_session_detects_nextauth_cookie() -> None: + page = MagicMock() + page.context.cookies.return_value = [ + {"name": "__Secure-next-auth.session-token", "value": "token"}, + ] + + assert _has_auth_session(page, "https://app.example.com") is True + + +def test_has_auth_session_ignores_unrelated_cookies() -> None: + page = MagicMock() + page.context.cookies.return_value = [{"name": "analytics", "value": "x"}] + + assert _has_auth_session(page, "https://app.example.com") is False + + +@patch("ess_load_test.auth.browser.BrowserSession") +def test_fetch_auth_header_via_browser_prefers_captured_bearer( + mock_session_cls: MagicMock, +) -> None: + mock_session = MagicMock() + mock_session_cls.return_value.__enter__.return_value = mock_session + mock_page = MagicMock() + mock_session.new_page.return_value = mock_page + mock_page.url = "https://app.example.com/" + mock_page.context.cookies.return_value = [] + + with patch("ess_load_test.auth.browser._wait_for_target_session") as wait_mock: + + def mark_captured( + _page, _url, *, headed, captured_headers, extra_auth_domains + ) -> None: + captured_headers.append("Authorization: Bearer captured") + + wait_mock.side_effect = mark_captured + + header = fetch_auth_header_via_browser( + "https://app.example.com", + profile_dir=_PROFILE_DIR, + headed=True, + ) + + assert header == "Authorization: Bearer captured" + mock_session_cls.assert_called_once() + launch_kwargs = mock_session_cls.call_args.kwargs + assert launch_kwargs["headed"] is True + + +@patch("ess_load_test.auth.browser.BrowserSession") +def test_fetch_auth_header_via_browser_prefers_captured_bearer_from_requests( + mock_session_cls: MagicMock, +) -> None: + mock_session = MagicMock() + mock_session_cls.return_value.__enter__.return_value = mock_session + mock_page = MagicMock() + mock_session.new_page.return_value = mock_page + mock_page.url = "https://app.example.com/" + mock_page.context.cookies.return_value = [] + + def register_request(handler) -> None: + request = MagicMock() + request.url = "https://app.example.com/api/health" + request.headers = {"authorization": "Bearer captured"} + handler(request) + + mock_page.on.side_effect = lambda _event, handler: register_request(handler) + + with patch("ess_load_test.auth.browser._wait_for_target_session"): + header = fetch_auth_header_via_browser( + "https://app.example.com", + profile_dir=_PROFILE_DIR, + headed=True, + ) + + assert header == "Authorization: Bearer captured" + + +@patch("ess_load_test.auth.browser.BrowserSession") +def test_fetch_auth_header_via_browser_falls_back_to_cookies( + mock_session_cls: MagicMock, +) -> None: + mock_session = MagicMock() + mock_session_cls.return_value.__enter__.return_value = mock_session + mock_page = MagicMock() + mock_session.new_page.return_value = mock_page + mock_page.context.cookies.return_value = [{"name": "session", "value": "abc"}] + + with patch("ess_load_test.auth.browser._wait_for_target_session"): + header = fetch_auth_header_via_browser( + "https://app.example.com", + profile_dir=_PROFILE_DIR, + headed=True, + ) + + assert header == "Cookie: session=abc" + + +@patch("ess_load_test.auth.browser.time.sleep") +@patch("ess_load_test.auth.browser.BrowserSession") +def test_extra_auth_domains_mark_the_login_page_as_sso( + mock_session_cls: MagicMock, + _sleep: MagicMock, +) -> None: + mock_session = MagicMock() + mock_session_cls.return_value.__enter__.return_value = mock_session + mock_page = MagicMock() + mock_session.new_page.return_value = mock_page + mock_page.url = "https://login.corp.example.net/authorize" + mock_session_cls.is_auth_redirect.side_effect = lambda *args, **kwargs: ( + "login.corp.example.net" in kwargs["extra_auth_domains"] + ) + + with pytest.raises(BrowserAuthError, match="headless"): + fetch_auth_header_via_browser( + "https://app.example.com", + profile_dir=_PROFILE_DIR, + headed=False, + extra_auth_domains=("login.corp.example.net",), + ) + + +def test_fetch_auth_header_via_browser_requires_target_url() -> None: + with pytest.raises(BrowserAuthError, match="Target URL is required"): + fetch_auth_header_via_browser(" ", profile_dir=_PROFILE_DIR) diff --git a/packages/python/ess-load-test/tests/test_config.py b/packages/python/ess-load-test/tests/test_config.py new file mode 100644 index 00000000..5facb1b8 --- /dev/null +++ b/packages/python/ess-load-test/tests/test_config.py @@ -0,0 +1,18 @@ +"""Unit tests for config loading.""" + +from ess_load_test.infra.config import _DEFAULT_WORKER_COUNT, LoadTestInfraConfig + + +class TestLoadTestInfraConfig: + def test_defaults(self): + config = LoadTestInfraConfig( + environment="dev", + instance_type="t3.large", + master_instance_type="t3.small", + worker_count=_DEFAULT_WORKER_COUNT, + use_default_vpc=True, + vpc_id_override=None, + subnet_ids_override=(), + ) + assert config.worker_count == _DEFAULT_WORKER_COUNT + assert config.use_default_vpc is True diff --git a/packages/python/ess-load-test/tests/test_instances.py b/packages/python/ess-load-test/tests/test_instances.py new file mode 100644 index 00000000..fb5e1953 --- /dev/null +++ b/packages/python/ess-load-test/tests/test_instances.py @@ -0,0 +1,58 @@ +"""Unit tests for EC2 instance provisioning helpers.""" + +from ess_load_test.app_config import default_app_config +from ess_load_test.infra.constants import get_tags +from ess_load_test.infra.instances import ( + EC2_USER_DATA_MAX_BYTES, + _build_user_data, + controller_hostname, + worker_hostname, +) + + +def test_controller_hostname() -> None: + assert controller_hostname("load-test-team-dev") == ( + "load-test-team-dev-locust-controller" + ) + + +def test_worker_hostname() -> None: + assert worker_hostname("load-test-team-dev", 0) == ( + "load-test-team-dev-locust-worker-0" + ) + + +def test_build_user_data_sets_hostname() -> None: + app_config = default_app_config() + hostname = "load-test-dev-locust-controller" + user_data = _build_user_data( + hostname, + locust_install_dir=app_config.locust_install_dir, + cloud_init_files=app_config.locust_bundle.sync_files, + ) + assert f"hostname: {hostname}" in user_data + assert f"hostnamectl set-hostname {hostname}" in user_data + assert "write_files:" not in user_data + assert len(user_data.encode()) <= EC2_USER_DATA_MAX_BYTES + + +def test_get_tags_includes_managed_by_and_environment() -> None: + tags = get_tags( + "ess-load-test", + "team-dev", + {"Name": "example", "Role": "locust-worker"}, + ) + assert tags["ManagedBy"] == "pulumi" + assert tags["Environment"] == "team-dev" + assert tags["Name"] == "example" + assert tags["Role"] == "locust-worker" + + +def test_get_tags_project_defaults_to_component() -> None: + assert get_tags("ess-load-test", "dev")["Project"] == "ess-load-test" + + +def test_get_tags_project_tag_overrides_component() -> None: + tags = get_tags("ess-load-test", "dev", project_tag="my-monorepo") + assert tags["Project"] == "my-monorepo" + assert tags["Component"] == "ess-load-test" diff --git a/packages/python/ess-load-test/tests/test_load_params.py b/packages/python/ess-load-test/tests/test_load_params.py new file mode 100644 index 00000000..0af92bc2 --- /dev/null +++ b/packages/python/ess-load-test/tests/test_load_params.py @@ -0,0 +1,172 @@ +"""Tests for shared Locust load-test parameters.""" + +from ess_load_test.app_config import default_app_config +from ess_load_test.cli.load_params import ( + LoadTestParams, + build_locust_load_flag_lines, + config_users_json, +) +from ess_load_test.cluster.runner import ( + LoadTestRunRequest, + LoadTestUiRequest, + WorkerRuntimeEnv, + _build_master_command, + _build_master_ui_command, + _build_worker_command, +) + + +def _shell_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def test_config_users_json_empty() -> None: + assert config_users_json(()) is None + + +def test_config_users_json_builds_array() -> None: + payload = config_users_json(("PagesUser", "BffUser")) + assert payload is not None + assert "PagesUser" in payload + assert "BffUser" in payload + + +def test_build_locust_load_flag_lines_headless() -> None: + params = LoadTestParams(users=50, spawn_rate=5.0, duration="5m") + lines = build_locust_load_flag_lines( + params, + headless=True, + shell_quote=_shell_quote, + ) + assert "-u 50" in lines + assert "-r 5.0" in lines + assert "-t '5m'" in lines + assert "--headless" in lines + assert "--autostart" not in lines + + +def test_build_locust_load_flag_lines_ui_autostart() -> None: + params = LoadTestParams(users=10, spawn_rate=2.0, autostart=True, autoquit=30) + lines = build_locust_load_flag_lines( + params, + headless=False, + shell_quote=_shell_quote, + ) + assert "--autostart" in lines + assert "--autoquit 30" in lines + assert "--headless" not in lines + + +def test_build_locust_load_flag_lines_omits_autoquit_without_autostart() -> None: + params = LoadTestParams(users=10, spawn_rate=2.0, autoquit=30) + lines = build_locust_load_flag_lines( + params, + headless=False, + shell_quote=_shell_quote, + ) + assert "--autoquit" not in lines + assert "--autostart" not in lines + + +def test_master_ui_command_includes_users_spawn_rate() -> None: + app_config = default_app_config() + request = LoadTestUiRequest( + stack="dev", + target_url="https://app.example.com", + paths="/", + scenario="mixed", + load_params=LoadTestParams(users=50, spawn_rate=5.0, duration="10m"), + auth_header=None, + profile="team-dev", + region="us-east-1", + ) + command = _build_master_ui_command( + request, + app_config, + master_private_ip="10.0.0.1", + worker_count=2, + ) + assert "-u 50" in command + assert "-r 5.0" in command + assert "-t '10m'" in command + assert "export LOAD_TEST_USERS=50" in command + assert "export LOAD_TEST_SPAWN_RATE=5.0" in command + assert "export LOAD_TEST_DURATION='10m'" in command + assert "export LOCUST_USERS=50" in command + assert "export LOCUST_SPAWN_RATE=5.0" in command + assert "export LOCUST_RUN_TIME='10m'" in command + assert "--class-picker" in command + assert "--autostart" not in command + + +def test_master_ui_command_config_users() -> None: + app_config = default_app_config() + request = LoadTestUiRequest( + stack="dev", + target_url="https://app.example.com", + paths="/", + scenario="mixed", + load_params=LoadTestParams( + user_classes=("PagesUser", "BffUser"), + ), + auth_header=None, + profile="team-dev", + region="us-east-1", + ) + command = _build_master_ui_command( + request, + app_config, + master_private_ip="10.0.0.1", + worker_count=2, + ) + assert "--config-users" in command + assert "PagesUser" in command + assert "BffUser" in command + + +def test_worker_command_exports_scenario_and_auth() -> None: + app_config = default_app_config() + runtime = WorkerRuntimeEnv( + scenario="mixed", + paths="/healthz", + auth_header="Bearer example-token", + ) + command = _build_worker_command(app_config, "10.0.0.1", runtime) + assert "export LOAD_TEST_SCENARIO='mixed'" in command + assert "export LOAD_TEST_PATHS='/healthz'" in command + assert "export LOAD_TEST_AUTH_HEADER='Bearer example-token'" in command + assert "--worker" in command + + +def test_master_command_headless_exports() -> None: + app_config = default_app_config() + _remote_csv_prefix = "/opt/my-load-test/stats" + _remote_html_path = "/opt/my-load-test/report.html" + + request = LoadTestRunRequest( + stack="dev", + target_url="https://app.example.com", + paths="/", + scenario="default", + load_params=LoadTestParams( + users=10, + spawn_rate=2.0, + duration="1m", + csv_prefix=_remote_csv_prefix, + html_path=_remote_html_path, + reset_stats=True, + ), + auth_header=None, + profile="team-dev", + region="us-east-1", + ) + command = _build_master_command( + request, + app_config, + master_private_ip="10.0.0.1", + worker_count=2, + ) + assert "--headless" in command + assert f"--csv '{_remote_csv_prefix}'" in command + assert f"--html '{_remote_html_path}'" in command + assert "--reset-stats" in command diff --git a/packages/python/ess-load-test/tests/test_local_port.py b/packages/python/ess-load-test/tests/test_local_port.py new file mode 100644 index 00000000..0c9ebdd8 --- /dev/null +++ b/packages/python/ess-load-test/tests/test_local_port.py @@ -0,0 +1,67 @@ +"""Tests for local port-forward troubleshooting helpers.""" + +from unittest.mock import MagicMock, patch + +import pytest +from ess_load_test.ui.local_port import ( + LocalPortExpectation, + check_local_port, + cleanup_stale_port_forward, + is_local_port_listening, + kill_stale_ssm_port_forward, +) + + +@patch("ess_load_test.ui.local_port.subprocess.run") +def test_is_local_port_listening_true_when_lsof_succeeds(mock_run) -> None: + mock_run.return_value.returncode = 0 + assert is_local_port_listening(8089) is True + mock_run.assert_called_once() + assert mock_run.call_args.args[0][:2] == ["lsof", "-i"] + + +@patch("ess_load_test.ui.local_port.subprocess.run") +def test_is_local_port_listening_false_when_lsof_fails(mock_run) -> None: + mock_run.return_value.returncode = 1 + assert is_local_port_listening(8089) is False + + +@patch("ess_load_test.ui.local_port.subprocess.run") +def test_kill_stale_ssm_port_forward_counts_matches(mock_run) -> None: + mock_run.side_effect = [ + MagicMock(returncode=0), + MagicMock(returncode=1), + ] + assert kill_stale_ssm_port_forward(8089) == 1 + pkill_pattern_count = 2 + assert mock_run.call_count == pkill_pattern_count + + +@patch("ess_load_test.ui.local_port.is_local_port_listening", return_value=False) +def test_check_local_port_free_succeeds_when_port_free(_mock_listening) -> None: + check_local_port(8089, expect=LocalPortExpectation.FREE) + + +@patch("ess_load_test.ui.local_port.is_local_port_listening", return_value=True) +def test_check_local_port_free_fails_when_port_in_use(_mock_listening) -> None: + with pytest.raises(RuntimeError, match="expected free"): + check_local_port(8089, expect=LocalPortExpectation.FREE) + + +@patch("ess_load_test.ui.local_port.is_local_port_listening", return_value=True) +def test_check_local_port_listening_succeeds_when_port_in_use(_mock_listening) -> None: + check_local_port(8089, expect=LocalPortExpectation.LISTENING) + + +@patch("ess_load_test.ui.local_port.is_local_port_listening", return_value=False) +def test_check_local_port_listening_fails_when_port_free(_mock_listening) -> None: + with pytest.raises(RuntimeError, match="expected listening"): + check_local_port(8089, expect=LocalPortExpectation.LISTENING) + + +@patch("ess_load_test.ui.local_port.kill_stale_ssm_port_forward", return_value=2) +@patch("ess_load_test.ui.local_port.is_local_port_listening", return_value=False) +def test_cleanup_stale_port_forward(_mock_listening, mock_kill) -> None: + killed_pattern_count = 2 + assert cleanup_stale_port_forward(8089) == killed_pattern_count + mock_kill.assert_called_once_with(8089) diff --git a/packages/python/ess-load-test/tests/test_locust_scenarios.py b/packages/python/ess-load-test/tests/test_locust_scenarios.py new file mode 100644 index 00000000..1114fa61 --- /dev/null +++ b/packages/python/ess-load-test/tests/test_locust_scenarios.py @@ -0,0 +1,65 @@ +"""Tests for Locust scenario runtime helpers.""" + +import pytest +from ess_load_test.locust.runtime import ( + InvalidScenarioError, + mark_response_failure, + normalize_scenario, +) + + +class TestNormalizeScenario: + def test_default_when_empty(self) -> None: + assert normalize_scenario(None) == "default" + assert normalize_scenario(" ") == "default" + + def test_accepts_known_scenarios(self) -> None: + scenarios = frozenset({"default", "pages"}) + assert normalize_scenario("pages", valid_scenarios=scenarios) == "pages" + + def test_rejects_unknown_scenario(self) -> None: + with pytest.raises(InvalidScenarioError, match="agent"): + normalize_scenario("agent") + + +class TestMarkResponseFailure: + def test_5xx_always_fails(self) -> None: + response = _FakeResponse(503) + mark_response_failure(response, strict=False) + assert response.failed is True + assert response.succeeded is False + + def test_4xx_strict_only(self) -> None: + response = _FakeResponse(401) + mark_response_failure(response, strict=False) + assert response.failed is False + assert response.succeeded is False + mark_response_failure(response, strict=True) + assert response.failed is True + + def test_accept_statuses_marks_success(self) -> None: + response = _FakeResponse(400) + mark_response_failure(response, accept_statuses=frozenset({400, 429})) + assert response.failed is False + assert response.succeeded is True + + def test_accept_statuses_does_not_override_5xx(self) -> None: + response = _FakeResponse(503) + mark_response_failure(response, accept_statuses=frozenset({503})) + assert response.failed is True + assert response.succeeded is False + + +class _FakeResponse: + def __init__(self, status_code: int) -> None: + self.status_code = status_code + self.failed = False + self.succeeded = False + self.message = "" + + def failure(self, message: str) -> None: + self.failed = True + self.message = message + + def success(self) -> None: + self.succeeded = True diff --git a/packages/python/ess-load-test/tests/test_locustfile_env.py b/packages/python/ess-load-test/tests/test_locustfile_env.py new file mode 100644 index 00000000..e5e99d00 --- /dev/null +++ b/packages/python/ess-load-test/tests/test_locustfile_env.py @@ -0,0 +1,143 @@ +"""Unit tests for locust environment helpers.""" + +from ess_load_test.locust.runtime import ( + apply_auth_header, + apply_auth_to_client, + default_profile_for_scenario, + is_auth_redirect, + mark_response_failure, + normalize_locust_host, + parse_load_test_paths, + read_auth_header, + read_load_test_duration, + read_load_test_spawn_rate, + read_load_test_users, +) + + +class TestParseLoadTestPaths: + def test_default_path(self): + assert parse_load_test_paths(None) == ("/",) + + def test_multiple_paths(self): + assert parse_load_test_paths("/healthz, /api/health") == ( + "/healthz", + "/api/health", + ) + + def test_empty_string_uses_default(self): + assert parse_load_test_paths(" , ") == ("/",) + + +class TestNormalizeLocustHost: + def test_strips_trailing_slash(self) -> None: + url = "https://app.example.com/" + assert normalize_locust_host(url) == "https://app.example.com" + + def test_preserves_bare_host(self) -> None: + url = "https://app.example.com" + assert normalize_locust_host(url) == url + + +class TestApplyAuthHeader: + def test_bearer_value(self): + headers: dict[str, str] = {} + apply_auth_header(headers, "Bearer example-token") + assert headers["Authorization"] == "Bearer example-token" + + def test_named_header(self): + headers: dict[str, str] = {} + apply_auth_header(headers, "X-API-KEY: secret") + assert headers["X-API-KEY"] == "secret" + + def test_none_is_noop(self): + headers = {"Existing": "1"} + apply_auth_header(headers, None) + assert headers == {"Existing": "1"} + + +class TestApplyAuthToClient: + def test_cookie_header_populates_cookie_jar(self): + class Cookies: + def __init__(self) -> None: + self.values: dict[str, str] = {} + + def clear(self) -> None: + self.values.clear() + + def set(self, name: str, value: str) -> None: + self.values[name] = value + + class Client: + def __init__(self) -> None: + self.headers: dict[str, str] = {} + self.cookies = Cookies() + + client = Client() + apply_auth_to_client( + client, + "Cookie: session=abc123; other=xyz", + ) + assert client.headers["Cookie"] == "session=abc123; other=xyz" + assert client.cookies.values == { + "session": "abc123", + "other": "xyz", + } + + +class TestReadAuthHeader: + def test_prefers_synced_file(self, tmp_path, monkeypatch) -> None: + auth_file = tmp_path / "auth.txt" + auth_file.write_text("Cookie: session=from-file", encoding="utf-8") + monkeypatch.setenv("LOAD_TEST_AUTH_HEADER_FILE", str(auth_file)) + monkeypatch.setenv("LOAD_TEST_AUTH_HEADER", "Cookie: session=from-env") + assert read_auth_header() == "Cookie: session=from-file" + + +class TestIsAuthRedirect: + def test_redirect_status_codes(self) -> None: + assert is_auth_redirect(307) is True + assert is_auth_redirect(200) is False + + +class TestMarkResponseFailureEnv: + def test_ok_status_not_marked(self): + class Response: + status_code = 200 + + def failure(self, _message: str) -> None: + raise AssertionError("should not fail") + + mark_response_failure(Response(), strict=True) + + +class TestDefaultProfileForScenario: + def test_mixed_scenario(self) -> None: + assert default_profile_for_scenario("mixed") == "mixed-run" + + def test_pages_scenario(self) -> None: + assert default_profile_for_scenario("pages") == "pages-run" + + def test_default_scenario(self) -> None: + assert default_profile_for_scenario("default") == "smoke" + + +class TestLoadTestEnvReaders: + _expected_users = 50 + _expected_spawn_rate = 5.0 + + def test_read_load_test_users(self, monkeypatch) -> None: + monkeypatch.delenv("LOAD_TEST_USERS", raising=False) + monkeypatch.delenv("LOCUST_USERS", raising=False) + assert read_load_test_users() is None + + monkeypatch.setenv("LOAD_TEST_USERS", str(self._expected_users)) + assert read_load_test_users() == self._expected_users + + def test_read_load_test_spawn_rate(self, monkeypatch) -> None: + monkeypatch.setenv("LOAD_TEST_SPAWN_RATE", str(int(self._expected_spawn_rate))) + assert read_load_test_spawn_rate() == self._expected_spawn_rate + + def test_read_load_test_duration(self, monkeypatch) -> None: + monkeypatch.setenv("LOAD_TEST_DURATION", "10m") + assert read_load_test_duration() == "10m" diff --git a/packages/python/ess-load-test/tests/test_runner.py b/packages/python/ess-load-test/tests/test_runner.py new file mode 100644 index 00000000..fc92a945 --- /dev/null +++ b/packages/python/ess-load-test/tests/test_runner.py @@ -0,0 +1,66 @@ +"""Tests for SSM runner helpers.""" + +from ess_load_test.app_config import default_app_config +from ess_load_test.cluster.runner import ( + _LOCUST_MASTER_PORT, + _LOCUST_STATS_PORT, + _WEB_UI_PORT, + _auth_file_bootstrap_lines, + _base64_write_file_command, + _bootstrap_locust_script, + _operator_scenario_env_exports, + _reset_locust_cluster_script, + _wrap_bash_script, +) + + +def test_wrap_bash_script_runs_under_bash() -> None: + wrapped = _wrap_bash_script("set -euo pipefail\necho ok") + assert wrapped.startswith("bash -c '") + assert "pipefail" in wrapped + + +def test_reset_cluster_script_stops_locust_and_frees_ports() -> None: + script = _reset_locust_cluster_script() + assert "pkill -f '[/]locust'" in script + assert f"fuser -k {_LOCUST_MASTER_PORT}/tcp" in script + assert f"fuser -k {_LOCUST_STATS_PORT}/tcp" in script + assert f"fuser -k {_WEB_UI_PORT}/tcp" in script + + +def test_base64_write_file_command_uses_shell_safe_decode() -> None: + command = _base64_write_file_command("/opt/locustfile.py", "YWJj") + assert "base64 -d" in command + assert "/opt/locustfile.py" in command + assert "python3 -c" not in command + + +def test_bootstrap_script_uses_install_dir() -> None: + install_dir = default_app_config().locust_install_dir + script = _bootstrap_locust_script(install_dir) + assert install_dir in script + + +def test_auth_file_bootstrap_lines_uses_mktemp() -> None: + lines = _auth_file_bootstrap_lines("Cookie: session=abc") + assert "_auth_file=$(mktemp)" in lines + assert 'base64 -d > "$_auth_file"' in lines + assert 'export LOAD_TEST_AUTH_HEADER_FILE="$_auth_file"' in lines + + +def test_auth_file_bootstrap_lines_empty_when_no_header() -> None: + assert _auth_file_bootstrap_lines(None) == "" + assert _auth_file_bootstrap_lines("") == "" + + +def test_operator_scenario_env_exports_forwards_chat_tuning(monkeypatch) -> None: + monkeypatch.delenv("LOAD_TEST_THINK_TIME", raising=False) + assert _operator_scenario_env_exports() == "" + + monkeypatch.setenv("LOAD_TEST_THINK_TIME", "15,30") + monkeypatch.setenv("LOAD_TEST_PRODUCT_SLUG", "__all__") + monkeypatch.setenv("LOAD_TEST_AWS_PROFILE", "team-dev") + exports = _operator_scenario_env_exports() + assert "export LOAD_TEST_THINK_TIME='15,30'" in exports + assert "export LOAD_TEST_PRODUCT_SLUG='__all__'" in exports + assert "LOAD_TEST_AWS_PROFILE" not in exports diff --git a/packages/python/ess-load-test/tests/test_security_group_rules.py b/packages/python/ess-load-test/tests/test_security_group_rules.py new file mode 100644 index 00000000..e7bca096 --- /dev/null +++ b/packages/python/ess-load-test/tests/test_security_group_rules.py @@ -0,0 +1,71 @@ +"""Unit tests for Locust security group rules.""" + +import pulumi_aws as aws +from ess_load_test.infra.network import ( + build_locust_security_group_egress, + build_locust_security_group_ingress, + has_public_internet_ingress, +) + +_LOCUST_MASTER_PORT = 5557 +_LOCUST_STATS_PORT = 5558 +_HTTP_PORT = 80 +_HTTPS_PORT = 443 +_DNS_PORT = 53 + + +class TestSecurityGroupRules: + def test_ingress_has_no_public_cidr(self): + ingress_rules = build_locust_security_group_ingress() + assert has_public_internet_ingress(ingress_rules) is False + + def test_ingress_allows_locust_coordination_ports(self): + ingress_rules = build_locust_security_group_ingress() + assert len(ingress_rules) == 1 + rule = ingress_rules[0] + assert rule.from_port == _LOCUST_MASTER_PORT + assert rule.to_port == _LOCUST_STATS_PORT + assert rule.self is True + + def test_egress_allows_http_https_and_dns(self): + egress_rules = build_locust_security_group_egress() + assert any( + rule.protocol == "tcp" and rule.from_port == _HTTP_PORT + for rule in egress_rules + ) + assert any( + rule.protocol == "tcp" and rule.from_port == _HTTPS_PORT + for rule in egress_rules + ) + assert any( + rule.protocol == "udp" and rule.from_port == _DNS_PORT + for rule in egress_rules + ) + + def test_egress_allows_locust_coordination_ports(self): + egress_rules = build_locust_security_group_egress() + assert any( + rule.protocol == "tcp" + and rule.from_port == _LOCUST_MASTER_PORT + and rule.to_port == _LOCUST_STATS_PORT + and rule.self is True + for rule in egress_rules + ) + + def test_detects_public_ipv4_ingress(self): + public_rule = aws.ec2.SecurityGroupIngressArgs( + protocol="tcp", + from_port=22, + to_port=22, + cidr_blocks=["0.0.0.0/0"], + ) + assert has_public_internet_ingress([public_rule]) is True + + def test_detects_public_ipv6_ingress(self): + public_rule = aws.ec2.SecurityGroupIngressArgs( + protocol="tcp", + from_port=22, + to_port=22, + ipv6_cidr_blocks=["::/0"], + ) + assert has_public_internet_ingress([public_rule]) is True diff --git a/packages/python/ess-load-test/tests/test_ui_workflow.py b/packages/python/ess-load-test/tests/test_ui_workflow.py new file mode 100644 index 00000000..18fa09c1 --- /dev/null +++ b/packages/python/ess-load-test/tests/test_ui_workflow.py @@ -0,0 +1,78 @@ +"""Tests for Locust UI workflow helpers.""" + +from unittest.mock import MagicMock, patch + +import pytest +from ess_load_test.app_config import default_app_config +from ess_load_test.ui.workflow import ( + UiWorkflowConfig, + UiWorkflowError, + build_port_forward_argv, + resolve_auth_header, + run_preflight, +) + + +def test_resolved_region_defers_to_stack_output_when_unset() -> None: + config = UiWorkflowConfig( + app_config=default_app_config(), + target_url="https://app.example.com", + ) + assert config.resolved_region is None + + +def test_build_port_forward_argv_includes_profile() -> None: + argv = build_port_forward_argv( + master_instance_id="i-abc", + region="us-east-1", + profile="team-dev", + local_port=8089, + ) + assert argv[:3] == ["aws", "--profile", "team-dev"] + assert "i-abc" in argv + assert "localPortNumber=8089,portNumber=8089" in argv + + +def test_resolve_auth_header_prefers_explicit() -> None: + resolve_token = MagicMock() + header = resolve_auth_header( + "X-API-KEY: secret", fetch_token=True, resolve_token=resolve_token + ) + assert header == "X-API-KEY: secret" + resolve_token.assert_not_called() + + +def test_resolve_auth_header_skips_fetch_when_disabled() -> None: + resolve_token = MagicMock() + assert ( + resolve_auth_header(None, fetch_token=False, resolve_token=resolve_token) + is None + ) + resolve_token.assert_not_called() + + +def test_run_preflight_requires_target_url() -> None: + config = UiWorkflowConfig( + app_config=default_app_config(), + target_url=" ", + ) + with pytest.raises(UiWorkflowError, match="Target URL"): + run_preflight(config) + + +@patch("ess_load_test.ui.workflow.boto3.Session") +def test_run_preflight_checks_aws_credentials(mock_session_cls: MagicMock) -> None: + identity = mock_session_cls.return_value.client.return_value.get_caller_identity + identity.return_value = { + "Account": "123", + } + config = UiWorkflowConfig( + app_config=default_app_config(), + target_url="https://app.example.com", + profile="team-dev", + region="us-east-1", + ) + run_preflight(config) + mock_session_cls.assert_called_once_with( + profile_name="team-dev", region_name="us-east-1" + ) diff --git a/packages/python/ess-outlook/.env.example b/packages/python/ess-outlook/.env.example index 03976e73..3a2066ee 100644 --- a/packages/python/ess-outlook/.env.example +++ b/packages/python/ess-outlook/.env.example @@ -1,5 +1,17 @@ -# Microsoft Entra (Azure AD) app registration +# Microsoft Entra (Azure AD) app registration. +# Both are REQUIRED unless GRAPH_ACCESS_TOKEN below is set -- there is no +# built-in default client ID. # CLIENT_ID: "Office Desktop Apps" pre-approved on M365 tenant, or your own app registration CLIENT_ID= # TENANT_ID: your organization's Azure AD tenant ID TENANT_ID= + +# Optional: bypass the device-code flow with a token pasted from Graph Explorer +# (https://developer.microsoft.com/graph/graph-explorer). Expires in ~1 hour. +# When set, CLIENT_ID and TENANT_ID are not needed. +# GRAPH_ACCESS_TOKEN= + +# Optional: prefer browser sign-in over the device-code flow. Requires CLIENT_ID +# to be an app registration listing "http://localhost" as a redirect URI, +# otherwise sign-in fails with AADSTS50011. +# GRAPH_INTERACTIVE_AUTH=1 diff --git a/packages/python/ess-outlook/LICENSE b/packages/python/ess-outlook/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/packages/python/ess-outlook/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/ess-outlook/README.md b/packages/python/ess-outlook/README.md index 8ca81c07..d18e5bd2 100644 --- a/packages/python/ess-outlook/README.md +++ b/packages/python/ess-outlook/README.md @@ -30,13 +30,36 @@ Then run `uv sync --all-packages` from the workspace root. ## Prerequisites -- `CLIENT_ID` and `TENANT_ID` set in the environment (see `.env.example`) - - `CLIENT_ID` -- the application (client) ID from a Microsoft Entra - (Azure AD) app registration with delegated `Mail.ReadWrite` - permission. The well-known "Office Desktop Apps" public client ID - works on most Microsoft 365 tenants without any registration. - - `TENANT_ID` -- your organization's Microsoft Entra tenant ID. - Python 3.12+ +- Credentials for one of the two authentication paths in + [Configuration](#configuration) + +## Configuration + +Set these in the environment or in this package's `.env` file (see +`.env.example`). The CLI loads that `.env` on startup from any working +directory, so no `source .env` is needed. Real environment variables take +precedence over the file. + +Boolean variables accept `1`, `true`, `yes`, `y`, `on`, `t` (and their +negatives), case-insensitively, via [python-decouple's][decouple] `strtobool`. +An unrecognized value raises `ValueError` rather than being treated as off, so +typos surface immediately. + +| Variable | Required | Description | +| ---- | ---- | ----- | +| `CLIENT_ID` | Yes, unless `GRAPH_ACCESS_TOKEN` is set | Application (client) ID of a Microsoft Entra app registration with delegated `Mail.ReadWrite`. | +| `TENANT_ID` | Yes, unless `GRAPH_ACCESS_TOKEN` is set | Your organization's Microsoft Entra tenant ID. | +| `GRAPH_ACCESS_TOKEN` | No | Pre-issued access token. Bypasses MSAL, so `CLIENT_ID` and `TENANT_ID` are then unused. See [Bring your own token](#bring-your-own-token). | +| `GRAPH_INTERACTIVE_AUTH` | No | Set to `1` for browser sign-in instead of a device code. See [Browser sign-in](#browser-sign-in). | + +There is no built-in default client ID. If neither `CLIENT_ID`/`TENANT_ID` +nor `GRAPH_ACCESS_TOKEN` is set, authentication raises `GraphAuthError`. + +The well-known "Office Desktop Apps" public client works on many Microsoft +365 tenants for the device-code flow without a dedicated registration, but +it has no `http://localhost` redirect URI, so it cannot be used with +`GRAPH_INTERACTIVE_AUTH`. ## Usage @@ -84,8 +107,40 @@ If your tenant blocks device-code flow via Conditional Access, run the command directly in your terminal session rather than over a remote shell. +### Browser sign-in + +Set `GRAPH_INTERACTIVE_AUTH=1` to sign in through the system browser +(authorization code + PKCE on a loopback redirect) instead of entering a +device code. Conditional Access policies often permit this where they +block device code. + +This requires `CLIENT_ID` to name an app registration that lists +`http://localhost` as a redirect URI; without one, sign-in fails with +`AADSTS50011`. Well-known first-party client IDs generally do not +qualify, so this is off by default. On success, MSAL caches a refresh token; +later runs renew silently. + +### Bring your own token + +Set `GRAPH_ACCESS_TOKEN` to skip MSAL entirely. `CLIENT_ID` and +`TENANT_ID` are then unnecessary: + +```bash +GRAPH_ACCESS_TOKEN="$(pbpaste)" ess-outlook list --limit 10 +``` + +Get a token from [Graph Explorer](https://developer.microsoft.com/graph/graph-explorer): +sign in, open the **Access token** tab, and copy the value. Consent to +`Mail.Read` (or `Mail.ReadWrite` for `mark-read` and `delete`) under +**Modify permissions** first, or requests return 403. + +Graph Explorer tokens expire after about an hour and are not cached, so +this suits one-off reads rather than unattended jobs. + ## Running Tests ```bash uv run pytest packages/python/ess-outlook/ ``` + +[decouple]: https://github.com/HBNetwork/python-decouple diff --git a/packages/python/ess-outlook/pyproject.toml b/packages/python/ess-outlook/pyproject.toml index 6a39385e..f18893c2 100644 --- a/packages/python/ess-outlook/pyproject.toml +++ b/packages/python/ess-outlook/pyproject.toml @@ -3,11 +3,13 @@ name = "ess-outlook" version = "0.1.0" description = "CLI tool for reading and managing Microsoft Outlook emails via Microsoft Graph" readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [ "click>=8.1", "ess-dirs", "msal>=1.28.0", + "python-decouple>=3.8", "python-dotenv>=1.0.0", "requests>=2.31.0", ] @@ -24,7 +26,7 @@ dev = [ ] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/packages/python/ess-outlook/src/ess_outlook/graph.py b/packages/python/ess-outlook/src/ess_outlook/graph.py index b57651b3..d9ca9911 100644 --- a/packages/python/ess-outlook/src/ess_outlook/graph.py +++ b/packages/python/ess-outlook/src/ess_outlook/graph.py @@ -2,21 +2,39 @@ from __future__ import annotations +import base64 +import binascii import json import logging import os +import time import webbrowser from datetime import datetime, timezone from http import HTTPStatus import msal import requests +from decouple import strtobool from ess_dirs import write_secure GRAPH_BASE = "https://graph.microsoft.com/v1.0/me" TOKEN_CACHE = os.path.expanduser("~/.cache/ess-outlook/graph_token_cache.json") SCOPES = ["Mail.ReadWrite"] +# Treat a token as spent this many seconds before its actual expiry, so a +# long-running command does not fail mid-flight. +_EXPIRY_BUFFER_SECONDS = 300 + +# Loopback port for the interactive flow. 0 lets MSAL pick any free port, +# which requires the app registration to list "http://localhost" as a redirect +# URI. Registrations without one fail with AADSTS50011, so the flow is opt-in +# via GRAPH_INTERACTIVE_AUTH rather than attempted by default. +_INTERACTIVE_REDIRECT_PORT = 0 + +# Cap the wait on the loopback redirect. Without this, a rejected redirect URI +# leaves the browser on an error page and MSAL listening forever. +_INTERACTIVE_TIMEOUT_SECONDS = 90 + logger = logging.getLogger(__name__) @@ -24,6 +42,27 @@ class GraphAuthError(Exception): """Raised when Graph API authentication fails.""" +def _cast_bool(value: str) -> bool: + """Coerce an environment value to a bool, tolerating stray whitespace. + + Uses decouple's vocabulary (``1``/``true``/``yes``/``y``/``on``/``t`` and + their negatives) and raises ``ValueError`` on anything else, so a typo + surfaces instead of silently leaving a flag off. + """ + value = value.strip() + return bool(strtobool(value)) if value else False + + +def _get_env(name: str) -> str: + """Read a stripped environment value. + + Reads the process environment only. Populating it from a ``.env`` file is + the entrypoint's job (``__main__.load_dotenv``), so importing this module + never picks up configuration the caller did not supply. + """ + return os.environ.get(name, "").strip() + + def _parse_date(value: str) -> datetime: """Parse an ISO-8601 date string into a timezone-aware datetime.""" if not value: @@ -36,8 +75,8 @@ def _parse_date(value: str) -> datetime: def _get_app() -> msal.PublicClientApplication: """Build an MSAL app using env vars for client/tenant.""" - client_id = os.environ.get("CLIENT_ID") - tenant_id = os.environ.get("TENANT_ID") + client_id = _get_env("CLIENT_ID") + tenant_id = _get_env("TENANT_ID") if not client_id or not tenant_id: msg = "CLIENT_ID and TENANT_ID must be set in environment or .env" raise GraphAuthError(msg) @@ -60,41 +99,146 @@ def _save_cache(app: msal.PublicClientApplication) -> None: write_secure(TOKEN_CACHE, cache.serialize()) -def get_token() -> str: - """Acquire a Graph API access token (silent refresh or device-code flow).""" - app = _get_app() +def _is_token_expired(token: str) -> bool: + """Report whether a JWT's ``exp`` claim has passed. - # 1. Try silent refresh from cached account - accounts = app.get_accounts() - if accounts: - result = app.acquire_token_silent(SCOPES, account=accounts[0]) - if result and "access_token" in result: - _save_cache(app) - return result["access_token"] + Unparseable tokens are reported as expired so the caller falls back to a + real sign-in rather than sending a malformed header. + """ + try: + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload)) + except (IndexError, ValueError, binascii.Error, UnicodeDecodeError): + return True + + expires_at = claims.get("exp") + if expires_at is None: + return True + + try: + expires_at = int(expires_at) + except (TypeError, ValueError): + return True + + return time.time() > (expires_at - _EXPIRY_BUFFER_SECONDS) + + +def _get_supplied_token() -> str | None: + """Return a usable ``GRAPH_ACCESS_TOKEN``, or None if absent or expired.""" + token = _get_env("GRAPH_ACCESS_TOKEN") + if not token: + return None + + if _is_token_expired(token): + logger.warning( + "GRAPH_ACCESS_TOKEN is expired or invalid; falling back to sign-in. " + "Paste a fresh token to skip this." + ) + os.environ.pop("GRAPH_ACCESS_TOKEN", None) + return None - # 2. Device-code flow -- open browser automatically + return token + + +def _is_interactive_enabled() -> bool: + """Report whether ``GRAPH_INTERACTIVE_AUTH`` opts in to browser sign-in.""" + return _cast_bool(_get_env("GRAPH_INTERACTIVE_AUTH")) + + +def _acquire_interactive(app: msal.PublicClientApplication) -> dict | None: + """Sign in through the system browser via a loopback redirect. + + Preferred over the device-code flow: Conditional Access policies commonly + block device code specifically, while allowing the same authorization-code + grant from a browser on a managed device. + + Opt in with ``GRAPH_INTERACTIVE_AUTH``, which requires an app registration + listing ``http://localhost`` as a redirect URI. Returns None when opted + out, the browser is unreachable, or the user never completes sign-in, + leaving device code as the fallback. + """ + if not _is_interactive_enabled(): + logger.debug("Interactive sign-in not enabled; skipping") + return None + + logger.info("Opening browser for Microsoft sign-in...") + try: + result = app.acquire_token_interactive( + SCOPES, + port=_INTERACTIVE_REDIRECT_PORT, + timeout=_INTERACTIVE_TIMEOUT_SECONDS, + ) + except (KeyboardInterrupt, SystemExit): + raise + except Exception: + logger.warning("Interactive sign-in failed; falling back to device code.") + logger.debug("Interactive sign-in unavailable", exc_info=True) + return None + + if result and "access_token" in result: + return result + + logger.warning( + "Interactive sign-in did not complete. If the browser showed " + "AADSTS50011 (redirect URI mismatch), client %s has no " + "'http://localhost' redirect URI -- use an app registration that " + "does, or set GRAPH_ACCESS_TOKEN.", + app.client_id, + ) + return None + + +def _acquire_by_device_code(app: msal.PublicClientApplication) -> dict: + """Sign in by entering a code at microsoft.com/devicelogin.""" flow = app.initiate_device_flow(scopes=SCOPES) if "user_code" not in flow: msg = flow.get("error_description", "Failed to initiate device flow") raise GraphAuthError(msg) - code = flow["user_code"] logger.warning( "\n Visit: https://microsoft.com/devicelogin\n Code: %s\n", - code, + flow["user_code"], ) webbrowser.open("https://microsoft.com/devicelogin") - result = app.acquire_token_by_device_flow(flow) + return app.acquire_token_by_device_flow(flow) + + +def get_token() -> str: + """Acquire a Graph API access token. + + Tries each source in turn: an unexpired ``GRAPH_ACCESS_TOKEN``, a silent + refresh from the on-disk cache, interactive browser sign-in (only when + ``GRAPH_INTERACTIVE_AUTH`` opts in), then the device-code flow. Only the + latter two prompt, and both cache a refresh token so subsequent calls + renew silently. + """ + supplied_token = _get_supplied_token() + if supplied_token: + return supplied_token + + app = _get_app() + + accounts = app.get_accounts() + if accounts: + result = app.acquire_token_silent(SCOPES, account=accounts[0]) + if result and "access_token" in result: + _save_cache(app) + return result["access_token"] + + result = _acquire_interactive(app) + if not result or "access_token" not in result: + result = _acquire_by_device_code(app) if "access_token" in result: _save_cache(app) return result["access_token"] - # 3. Auth failed -- give actionable error error = result.get("error_description", "Authentication failed") msg = ( f"Authentication failed: {error}\n" - "If blocked by Conditional Access, try from your terminal." + "If Conditional Access blocks these flows, set GRAPH_ACCESS_TOKEN " + "from https://developer.microsoft.com/graph/graph-explorer." ) raise GraphAuthError(msg) @@ -103,6 +247,11 @@ def _handle_response(resp: requests.Response) -> dict: """Raise on HTTP errors, return parsed JSON.""" if resp.status_code == HTTPStatus.UNAUTHORIZED: msg = "Token expired or invalid. Re-run to authenticate." + if _get_env("GRAPH_ACCESS_TOKEN"): + msg = ( + "GRAPH_ACCESS_TOKEN was rejected. Confirm it grants Mail.Read " + "(Mail.ReadWrite to modify), or unset it to sign in instead." + ) raise GraphAuthError(msg) resp.raise_for_status() return resp.json() diff --git a/packages/python/ess-passwords/LICENSE b/packages/python/ess-passwords/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/packages/python/ess-passwords/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/ess-passwords/pyproject.toml b/packages/python/ess-passwords/pyproject.toml index df957ea5..63a5474d 100644 --- a/packages/python/ess-passwords/pyproject.toml +++ b/packages/python/ess-passwords/pyproject.toml @@ -3,6 +3,7 @@ name = "ess-passwords" version = "0.1.0" description = "Readable password generation from a filtered EFF wordlist" readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [ "click>=8.1", @@ -18,7 +19,7 @@ dev = [ ess-passwords = "ess_passwords.__main__:main" [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build] diff --git a/packages/python/ess-passwords/src/ess_passwords/__main__.py b/packages/python/ess-passwords/src/ess_passwords/__main__.py index 3b7cf0f4..797e8ddd 100644 --- a/packages/python/ess-passwords/src/ess_passwords/__main__.py +++ b/packages/python/ess-passwords/src/ess_passwords/__main__.py @@ -1,11 +1,11 @@ """CLI entry point for ess-passwords. -Every command here is read-only: it reads the wordlist, prints a password, -and writes nothing anywhere. `.cursor/rules/cli-write-safety.mdc` therefore -forbids `--dry-run` and a confirmation prompt -- there is no state to -preview and nothing to consent to. Do not add them: generating a password -is not the same as rotating one, and a decorative `--dry-run` teaches -operators to distrust the real ones. +Commands that write state need `--dry-run`, a confirmation prompt by default, +and `--yes`. Read-only commands get neither: there is nothing to preview or +confirm. Every command here only reads the wordlist and prints a password, so +none of them take those flags. Do not add them: generating a password is not +the same as rotating one, and a decorative `--dry-run` teaches operators to +distrust the real ones. """ from __future__ import annotations diff --git a/packages/python/ess-passwords/src/ess_passwords/test_main.py b/packages/python/ess-passwords/src/ess_passwords/test_main.py index 633bcfba..682b7d33 100644 --- a/packages/python/ess-passwords/src/ess_passwords/test_main.py +++ b/packages/python/ess-passwords/src/ess_passwords/test_main.py @@ -94,8 +94,8 @@ def test_impossible_max_length_reports_the_policy_error() -> None: def test_generate_offers_no_dry_run() -> None: - # Read-only per cli-write-safety.mdc: there is nothing to preview, - # so the flag must not exist rather than being a silent no-op. + # Read-only commands take no --dry-run: there is nothing to preview, so the + # flag must not exist rather than being a silent no-op. result = CliRunner().invoke(main, ["generate", "--dry-run"]) assert result.exit_code != 0 diff --git a/packages/python/ess-service-now-incident/LICENSE b/packages/python/ess-service-now-incident/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/packages/python/ess-service-now-incident/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/ess-service-now-incident/README.md b/packages/python/ess-service-now-incident/README.md index 878620b0..f1d2a00d 100644 --- a/packages/python/ess-service-now-incident/README.md +++ b/packages/python/ess-service-now-incident/README.md @@ -102,11 +102,19 @@ Wrappers can use `build_cli()` to construct a CLI with an organization-specific default. The user can still override with `--instance` or `SERVICENOW_INSTANCE`. +If your SSO lands on a host that `ess_browser.AUTH_DOMAINS` does not +match, pass it as `extra_auth_domains`. Otherwise the login page is +mistaken for ServiceNow and the run fails instead of waiting for login. +`get_incident()` takes the same argument. + ```python # tools/python/my-team-tool/src/my_team_tool/__main__.py from ess_service_now_incident import build_cli -main = build_cli(default_instance="my-org.service-now.com") +main = build_cli( + default_instance="my-org.service-now.com", + extra_auth_domains=("login.my-org.example.com",), +) if __name__ == "__main__": main() diff --git a/packages/python/ess-service-now-incident/pyproject.toml b/packages/python/ess-service-now-incident/pyproject.toml index 04ec17d0..c42bce53 100644 --- a/packages/python/ess-service-now-incident/pyproject.toml +++ b/packages/python/ess-service-now-incident/pyproject.toml @@ -3,6 +3,7 @@ name = "ess-service-now-incident" version = "0.1.0" description = "Fetch ServiceNow incident records via an authenticated browser session" readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [ "ess-browser", @@ -21,7 +22,7 @@ dev = [ ] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/packages/python/ess-service-now-incident/src/ess_service_now_incident/cli.py b/packages/python/ess-service-now-incident/src/ess_service_now_incident/cli.py index ba2ec7b6..cdd57207 100644 --- a/packages/python/ess-service-now-incident/src/ess_service_now_incident/cli.py +++ b/packages/python/ess-service-now-incident/src/ess_service_now_incident/cli.py @@ -19,7 +19,11 @@ INSTANCE_ENV_VAR = "SERVICENOW_INSTANCE" -def build_cli(*, default_instance: str | None = None) -> click.Command: +def build_cli( + *, + default_instance: str | None = None, + extra_auth_domains: tuple[str, ...] = (), +) -> click.Command: """Build a Click command for fetching ServiceNow incidents. Args: @@ -29,6 +33,8 @@ def build_cli(*, default_instance: str | None = None) -> click.Command: explicit ``--instance`` flag wins over both. Pass ``None`` to require the user to supply an instance (either via the flag, the environment variable, or a full URL identifier). + extra_auth_domains: Your organization's IdP host patterns, passed + to :func:`get_incident` so its SSO login is recognized. Returns: A :class:`click.Command` ready to be exposed as a console @@ -101,6 +107,7 @@ def main( # noqa: PLR0913 instance=instance, headed=headed, profile_dir=profile_dir, + extra_auth_domains=extra_auth_domains, ) except ServiceNowIncidentError as exc: click.echo(f"Error: {exc}", err=True) diff --git a/packages/python/ess-service-now-incident/src/ess_service_now_incident/client.py b/packages/python/ess-service-now-incident/src/ess_service_now_incident/client.py index e1cbd218..e4ffdaec 100644 --- a/packages/python/ess-service-now-incident/src/ess_service_now_incident/client.py +++ b/packages/python/ess-service-now-incident/src/ess_service_now_incident/client.py @@ -159,6 +159,7 @@ def _fetch_via_browser( target_url: str, headed: bool, profile_dir: str, + extra_auth_domains: tuple[str, ...] = (), ) -> tuple[int, str]: """Run the Table API call inside an authenticated browser session. @@ -173,7 +174,11 @@ def _fetch_via_browser( logger.info("Navigating to %s", target_url) page.goto(target_url, wait_until="load", timeout=_NAV_TIMEOUT_MS) - if session.is_auth_redirect(page.url, target_url): + if session.is_auth_redirect( + page.url, + target_url, + extra_auth_domains=extra_auth_domains, + ): logger.info("SSO redirect detected -- waiting for login") try: session.wait_for_login(page, target_url, timeout_ms=LOGIN_TIMEOUT_MS) @@ -240,6 +245,7 @@ def get_incident( instance: str | None = None, headed: bool = False, profile_dir: str | None = None, + extra_auth_domains: tuple[str, ...] = (), ) -> dict[str, Any]: """Fetch an incident record from a ServiceNow instance. @@ -254,6 +260,9 @@ def get_incident( headed: Show the browser window for first-time SSO login. profile_dir: Browser profile directory for session persistence. Defaults to ``DEFAULT_PROFILE_DIR`` when omitted. + extra_auth_domains: IdP host patterns to recognize as an SSO + redirect in addition to ``ess_browser.AUTH_DOMAINS``. Name + your organization's own login hosts here. Returns: A dict with keys ``number``, ``sys_id``, ``short_description``, @@ -275,5 +284,6 @@ def get_incident( target_url=f"https://{effective_instance}/", headed=headed, profile_dir=effective_profile, + extra_auth_domains=extra_auth_domains, ) return _unwrap_result(query, status, body) diff --git a/packages/python/ess-service-now-incident/tests/test_cli.py b/packages/python/ess-service-now-incident/tests/test_cli.py index be7299b8..576d643d 100644 --- a/packages/python/ess-service-now-incident/tests/test_cli.py +++ b/packages/python/ess-service-now-incident/tests/test_cli.py @@ -42,6 +42,17 @@ def test_returns_click_command(self): cli = build_cli() assert isinstance(cli, click.Command) + def test_extra_auth_domains_reach_get_incident(self): + cli = build_cli( + default_instance="example.service-now.com", + extra_auth_domains=("login.corp.example.net",), + ) + mock_get, result = _invoke(cli, ["INC0000001"], env=_ENV_UNSET) + assert result.exit_code == 0 + assert mock_get.call_args.kwargs["extra_auth_domains"] == ( + "login.corp.example.net", + ) + class TestInstancePrecedence: """Runtime tests for the --instance / env var / default precedence.""" diff --git a/packages/python/ess-service-now-incident/tests/test_client.py b/packages/python/ess-service-now-incident/tests/test_client.py index c2479cf5..470be953 100644 --- a/packages/python/ess-service-now-incident/tests/test_client.py +++ b/packages/python/ess-service-now-incident/tests/test_client.py @@ -16,10 +16,14 @@ class TestNormalizeUrl: def test_already_has_https(self): - assert _normalize_url("https://x.com/p") == "https://x.com/p" + assert ( + _normalize_url("https://host.example.com/p") == "https://host.example.com/p" + ) def test_already_has_http(self): - assert _normalize_url("http://x.com/p") == "http://x.com/p" + assert ( + _normalize_url("http://host.example.com/p") == "http://host.example.com/p" + ) def test_schemeless_url(self): assert ( diff --git a/packages/python/ess-service-now-incident/tests/test_get_incident.py b/packages/python/ess-service-now-incident/tests/test_get_incident.py index 2e6c46d6..886754ad 100644 --- a/packages/python/ess-service-now-incident/tests/test_get_incident.py +++ b/packages/python/ess-service-now-incident/tests/test_get_incident.py @@ -138,6 +138,17 @@ def test_sso_timeout_raises_authentication_error(self): ): get_incident("INC0000001", instance=_INSTANCE) + def test_extra_auth_domains_reach_the_redirect_check(self): + session = _make_session() + with patch(_PATCH_TARGET, return_value=session): + get_incident( + "INC0000001", + instance=_INSTANCE, + extra_auth_domains=("login.corp.example.net",), + ) + kwargs = session.is_auth_redirect.call_args.kwargs + assert kwargs["extra_auth_domains"] == ("login.corp.example.net",) + class TestGetIncidentApiErrors: def test_non_ok_http_raises_api_error(self): diff --git a/packages/python/ess-webex/LICENSE b/packages/python/ess-webex/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/packages/python/ess-webex/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/ess-webex/pyproject.toml b/packages/python/ess-webex/pyproject.toml index de449f40..3b5ab71c 100644 --- a/packages/python/ess-webex/pyproject.toml +++ b/packages/python/ess-webex/pyproject.toml @@ -3,6 +3,7 @@ name = "ess-webex" version = "0.1.0" description = "CLI tool for interacting with Webex rooms, messages, teams, and meetings" readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [ "click>=8.1", @@ -23,7 +24,7 @@ dev = [ ] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/packages/python/langsmith-network/LICENSE b/packages/python/langsmith-network/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/packages/python/langsmith-network/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/langsmith-network/pyproject.toml b/packages/python/langsmith-network/pyproject.toml index 272d0c83..8d18d424 100644 --- a/packages/python/langsmith-network/pyproject.toml +++ b/packages/python/langsmith-network/pyproject.toml @@ -2,11 +2,12 @@ name = "langsmith-network" version = "0.1.0" description = "LangSmith Cloud NAT gateway IP addresses for firewall rules" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/pyproject.toml b/pyproject.toml index e68fbe94..c246ac6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ members = [ "packages/python/ess-browser", "packages/python/ess-dirs", "packages/python/ess-langsmith-client", + "packages/python/ess-load-test", "packages/python/ess-outlook", "packages/python/ess-passwords", "packages/python/ess-service-now-incident", diff --git a/skills/README.md b/skills/README.md index 43c9b978..8204f013 100644 --- a/skills/README.md +++ b/skills/README.md @@ -12,7 +12,7 @@ From a local clone (run in the repo root): ```bash npx skills add ./skills --skill '*' --full-depth # all skills -npx skills add ./skills --skill mcp-hide-secrets --full-depth # a single skill +npx skills add ./skills --skill ess-mcp-hide-secrets --full-depth # a single skill npx skills add ./skills --list --full-depth # list without installing ``` @@ -38,17 +38,24 @@ npx skills add CiscoDevNet/essentials --skill '*' --full-depth | Skill | What it does | | ----- | ------------ | -| [`ess/pr-address-comments`](ess/pr-address-comments/) | Read a remote GitHub PR's review comments, implement fixes, then reply to and resolve the addressed threads. | -| [`ess/pr-address-comments-all`](ess/pr-address-comments-all/) | Address review comments across one or more PRs in parallel, each in its own git worktree on the PR's branch. | -| [`ess/mcp-hide-secrets`](ess/mcp-hide-secrets/) | Move Cursor MCP credentials out of `mcp.json` into a login-loaded `mcp.env` (macOS LaunchAgent). | -| [`ess/summarize-change-log`](ess/summarize-change-log/) | Condense a long git log or squash message into 1–5 Conventional-Commit bullets. | -| [`ess/git-tidy`](ess/git-tidy/) | Audit local branches, remote branches, and worktrees, then delete only what you approve at a single gate. | +| [`ess/ess-pr-address-comments`](ess/ess-pr-address-comments/) | Address review comments on one or more PRs, each in its own git worktree on the PR's branch, then reply to and resolve the addressed threads. | +| [`ess/ess-pr-review`](ess/ess-pr-review/) | Review one or more PRs: repo linters run the deterministic checks, the agent adds judgment, and findings post after one approval. | +| [`ess/ess-gh-copilot-review`](ess/ess-gh-copilot-review/) | Review the current branch with GitHub Copilot CLI: the whole branch, only what changed since the last Copilot review, or a given range. | +| [`ess/ess-mcp-hide-secrets`](ess/ess-mcp-hide-secrets/) | Move Cursor MCP credentials out of `mcp.json` into a login-loaded `mcp.env` (macOS LaunchAgent). | +| [`ess/ess-summarize-change-log`](ess/ess-summarize-change-log/) | Condense a long git log or squash message into 1–5 Conventional-Commit bullets. | +| [`ess/ess-git-tidy`](ess/ess-git-tidy/) | Audit local branches, remote branches, and worktrees, then delete only what you approve at a single gate. | +| [`ess/ess-my-groups`](ess/ess-my-groups/) | Resolve nicknames and group names, which may overlap and nest, to people with emails and service handles, and maintain those groups. | +| [`ess/ess-git-commits`](ess/ess-git-commits/) | Commit and open PRs as an agent without agent attribution, using a commit-msg hook that strips the injected trailer. | +| [`ess/ess-gh-repo-mirror`](ess/ess-gh-repo-mirror/) | Mirror a GitHub repo or Action, including its tags, into another owner or org, or sync an existing mirror. | +| [`ess/ess-env-credentials`](ess/ess-env-credentials/) | Recover from missing environment variables or credentials by asking to source the project's `.env`, never reading it. | +| [`ess/ess-shell-flags-literal`](ess/ess-shell-flags-literal/) | Write command-line flags out literally instead of packing them into one shell variable. | +| [`ess/ess-django-docs`](ess/ess-django-docs/) | Answer Django questions from current documentation through the Context7 MCP. | +| [`ess/ess-postgres-docs`](ess/ess-postgres-docs/) | Answer PostgreSQL and `psql` questions from current documentation through the Context7 MCP. | | [`ess/essentials-sync`](ess/essentials-sync/) | Extract a jargon-free `ess-*` package out of a private tool and sync it to the open-source essentials repo. | ## Notes -- Cursor-only behavior such as `disable-model-invocation: true` (slash-command-only activation) is kept out of the committed `SKILL.md`. To enable it locally, see [`ess/mcp-hide-secrets/references/install-cursor.md`](ess/mcp-hide-secrets/references/install-cursor.md). -- `pr-address-comments-all` invokes its scripts from their committed location (`skills/ess/pr-address-comments-all/scripts/`), so they are present in every checkout and worktree of a repo that vendors these skills. +- Cursor-only behavior such as `disable-model-invocation: true` (slash-command-only activation) is kept out of the committed `SKILL.md`. To enable it locally, see [`ess/ess-mcp-hide-secrets/references/install-cursor.md`](ess/ess-mcp-hide-secrets/references/install-cursor.md). ## License diff --git a/skills/ess/ess-django-docs/LICENSE b/skills/ess/ess-django-docs/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/skills/ess/ess-django-docs/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/ess/ess-django-docs/SKILL.md b/skills/ess/ess-django-docs/SKILL.md new file mode 100644 index 00000000..7be35d6a --- /dev/null +++ b/skills/ess/ess-django-docs/SKILL.md @@ -0,0 +1,17 @@ +--- +name: ess-django-docs +description: >- + Answer Django questions from current documentation through the Context7 MCP + instead of training data. Use when the user asks about Django APIs, settings, + the ORM, migrations, management commands, or Django REST Framework details. +license: Apache-2.0 +compatibility: Requires the Context7 MCP server. +--- + +When answering Django questions, use the Context7 MCP. Do not rely on +training data for Django API details. + +1. Call `resolve-library-id` with library name `Django` unless you already + have a Django library id. +2. Call `query-docs` with that library id. Keep each query to one Django + API or setting. diff --git a/skills/ess/ess-env-credentials/LICENSE b/skills/ess/ess-env-credentials/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/skills/ess/ess-env-credentials/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/ess/ess-env-credentials/SKILL.md b/skills/ess/ess-env-credentials/SKILL.md new file mode 100644 index 00000000..da0753cf --- /dev/null +++ b/skills/ess/ess-env-credentials/SKILL.md @@ -0,0 +1,40 @@ +--- +name: ess-env-credentials +description: >- + Recover from a command that fails for missing environment variables or + credentials by asking to source the project's .env file, never reading or + printing it. Use when a command fails with errors such as "Missing required + environment variable", "API key not found", "Authentication failed", + "Credentials not configured", or "passphrase must be set with + PULUMI_CONFIG_PASSPHRASE". +license: Apache-2.0 +compatibility: Requires a POSIX shell (bash or zsh). +--- + +# Environment Variables and Credentials + +When a command fails due to missing environment variables or credentials, such as: + +- `Missing required environment variable` +- `API key not found` +- `Authentication failed` +- `Credentials not configured` +- `passphrase must be set with PULUMI_CONFIG_PASSPHRASE` + +Do NOT tell the user to run commands in their own terminal. + +## Resolution Steps + +1. Check if a `.env` file exists in the current project directory +2. Ask the user: "I encountered a credentials/environment variable error. Can I source the `.env` file to get the required values?" +3. If approved, run commands by sourcing the .env first: + +```bash +set -a && source .env && set +a && +``` + +## Security Notes + +- Never read or display the contents of `.env` files (they contain secrets) +- Always verify `.env` is gitignored before proceeding +- The `.env` file should contain the required environment variables diff --git a/skills/ess/ess-gh-copilot-review/LICENSE b/skills/ess/ess-gh-copilot-review/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/skills/ess/ess-gh-copilot-review/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/ess/ess-gh-copilot-review/SKILL.md b/skills/ess/ess-gh-copilot-review/SKILL.md new file mode 100644 index 00000000..bbb2513d --- /dev/null +++ b/skills/ess/ess-gh-copilot-review/SKILL.md @@ -0,0 +1,86 @@ +--- +name: ess-gh-copilot-review +description: >- + Review the current git branch with GitHub Copilot CLI: the full branch against + the remote's default branch, only the changes since the last Copilot review, or + an explicit revision or range. Use when asked to "review this branch with Copilot", "get a + Copilot review", "Copilot review since last time", or "have Copilot check + HEAD~3..HEAD". +license: Apache-2.0 +compatibility: >- + Requires bash, git, and GitHub Copilot CLI (`copilot` on PATH). Must run outside + any agent sandbox, because Copilot CLI spawns subprocesses that need PTY access. +--- + +# Review a Branch with GitHub Copilot CLI + +Review the current branch against the remote's default branch using GitHub +Copilot CLI. + +## Usage + +Pass at most one scope option to the review script: + +| Option | Reviews | +| ------ | ------- | +| _(none)_ | The whole branch against the remote's default branch | +| `--since-last` | Only changes since the last successful review on this branch | +| `--since ` | Changes from `` to `HEAD`, e.g. `--since HEAD~1` | +| `--range ` | An explicit range, e.g. `--range HEAD~3..HEAD` | +| `--reset-last` | The whole branch, after clearing the last-reviewed marker | + +`--since` and `--range` need no remote fetch. + +The remote is `origin`, or the only remote when there is no `origin`. Its default +branch comes from `refs/remotes//HEAD` (set by `git clone`), falling back +to `main`, then `master`. Pass `--base ` (e.g. `--base upstream/develop`) when +there are several remotes and none is `origin`, or the base is some other branch. +`--base` applies to the whole-branch review, including `--since-last` without a +marker. + +## Prerequisites + +- **GitHub Copilot CLI** installed (`copilot` on `$PATH`). + See [Getting started with GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli/cli-getting-started) for install instructions. + +## Instructions + +Run the bundled review script from inside the repository to review, **outside any +agent sandbox**. Copilot CLI spawns subprocesses that need PTY access, which +sandboxes block. `SKILL_DIR` is the absolute path of the directory that contains +this `SKILL.md`; the script reviews whichever repo the current directory is in. + +```bash +bash "$SKILL_DIR/scripts/review-pr.sh" [option] +``` + +The script runs `copilot -p ... --allow-all` (non-interactive, all tools +auto-approved) so no manual confirmation is needed. Only use this in trusted +repositories -- `--allow-all` permits Copilot to run shell commands and modify +files without confirmation. + +This will: + +1. Verify Copilot CLI is installed (exits with install instructions if not). +2. Fetch the remote (only for the whole-branch review) and show a diff stat for the selected range. +3. Run a non-interactive Copilot CLI review that checks for correctness issues, edge cases, security concerns, missing tests, performance problems, and maintainability issues. + +After every successful run the script records the current `HEAD` SHA in a +per-branch marker file under `.ess-gh-copilot-review/` inside the git directory. +Named branches use the common git directory, so every worktree of that branch +shares one marker. A detached checkout uses that worktree's own git directory, +so two detached checkouts do not share a `--since-last` marker. Markers are +never tracked. When you pass `--since-last`, it reviews only changes since that +marker. Use `--reset-last` to clear it and fall back to the whole-branch diff. + +Relay Copilot's findings to the user. + +## Validation + +```bash +bash "$SKILL_DIR/scripts/test_review_pr.sh" +``` + +Runs the script against throwaway repos with a local bare remote and Copilot +stubbed out, and checks argument parsing, base detection, and the per-branch +marker. diff --git a/skills/ess/ess-gh-copilot-review/scripts/review-pr.sh b/skills/ess/ess-gh-copilot-review/scripts/review-pr.sh new file mode 100644 index 00000000..29fd5f81 --- /dev/null +++ b/skills/ess/ess-gh-copilot-review/scripts/review-pr.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Prints the remote to compare against: origin if it exists, else the only remote. +get_review_remote() { + local remotes + remotes=$(git remote) + if grep -qx origin <<<"$remotes"; then + echo origin + return 0 + fi + if [[ -n "$remotes" && $(grep -c . <<<"$remotes") -eq 1 ]]; then + echo "$remotes" + return 0 + fi + return 1 +} + +# Prints the remote's default branch as "/", from its HEAD ref +# when the clone recorded one, else main or master. +get_default_base() { + local remote="$1" + local head_ref + if head_ref=$(git symbolic-ref --quiet --short "refs/remotes/${remote}/HEAD" 2>/dev/null); then + echo "$head_ref" + return 0 + fi + local candidate + for candidate in main master; do + if git rev-parse --verify --quiet "refs/remotes/${remote}/${candidate}" >/dev/null; then + echo "${remote}/${candidate}" + return 0 + fi + done + return 1 +} + +review_pr() { + if ! git rev-parse --show-toplevel >/dev/null 2>&1; then + echo "Error: not inside a git repository." >&2 + return 1 + fi + + local branch_name + branch_name=$(git rev-parse --abbrev-ref HEAD) + local safe_branch="${branch_name//\//-}" + # Named branches share one marker. Detached checkouts all report HEAD, so each + # keeps its marker in that worktree's git directory instead of the common one. + local git_dir_flag="--git-common-dir" + if [[ "$branch_name" == "HEAD" ]]; then + git_dir_flag="--git-dir" + fi + local marker_dir + marker_dir="$(git rev-parse --path-format=absolute "$git_dir_flag")/.ess-gh-copilot-review" + local state_file="${marker_dir}/${safe_branch}" + + local use_since_last="false" + local manual_since="" + local manual_range="" + local manual_base="" + local reset_last="false" + + while [[ $# -gt 0 ]]; do + case "$1" in + --since-last) + use_since_last="true" + shift + ;; + --since) + if [[ $# -lt 2 ]]; then + echo "Error: --since requires a git revision." >&2 + return 1 + fi + manual_since="$2" + shift 2 + ;; + --range) + if [[ $# -lt 2 ]]; then + echo "Error: --range requires a git revision range." >&2 + return 1 + fi + manual_range="$2" + shift 2 + ;; + --base) + if [[ $# -lt 2 ]]; then + echo "Error: --base requires a git ref." >&2 + return 1 + fi + manual_base="$2" + shift 2 + ;; + --reset-last) + reset_last="true" + shift + ;; + *) + echo "Error: Unknown argument: $1" >&2 + return 1 + ;; + esac + done + + if ! command -v copilot >/dev/null 2>&1; then + cat >&2 <<'ERR' +Error: GitHub Copilot CLI is not installed. + +Install it using one of: + brew install copilot-cli # macOS/Linux (Homebrew) + npm install -g @github/copilot # Cross-platform (Node.js 22+) + winget install GitHub.Copilot # Windows (WinGet) + +See: https://docs.github.com/en/copilot/how-tos/copilot-cli/cli-getting-started +ERR + return 1 + fi + + local reviewed_sha + reviewed_sha=$(git rev-parse HEAD) + + local diff_range + local prompt_scope + local needs_base="false" + + if [[ -n "$manual_range" ]]; then + diff_range="$manual_range" + prompt_scope="the range ${manual_range}" + elif [[ -n "$manual_since" ]]; then + if ! git rev-parse --verify "$manual_since" >/dev/null 2>&1; then + echo "Error: revision '$manual_since' not found." >&2 + return 1 + fi + diff_range="${manual_since}..${reviewed_sha}" + prompt_scope="changes since ${manual_since}" + elif [[ "$use_since_last" == "true" && "$reset_last" != "true" ]]; then + if [[ -f "$state_file" ]]; then + local last_sha + last_sha=$(cat "$state_file") + if git rev-parse --verify "$last_sha" >/dev/null 2>&1; then + diff_range="${last_sha}..${reviewed_sha}" + prompt_scope="changes since ${last_sha}" + else + echo "Warning: last-reviewed commit '$last_sha' not found. Falling back to the default branch." >&2 + needs_base="true" + fi + else + needs_base="true" + fi + else + needs_base="true" + fi + + if [[ "$needs_base" == "true" ]]; then + local remote="" + local base + if [[ -n "$manual_base" ]]; then + base="$manual_base" + # Fetch only when the base is a remote-tracking ref. Accept both + # upstream/develop and refs/remotes/upstream/develop. + local remote_base="${manual_base#refs/remotes/}" + if git remote | grep -qx "${remote_base%%/*}"; then + remote="${remote_base%%/*}" + fi + else + if ! remote=$(get_review_remote); then + echo "Error: no single remote to compare against; pass --base ." >&2 + return 1 + fi + fi + + if [[ -n "$remote" ]]; then + git fetch "$remote" 2>&1 || return 1 + fi + + if [[ -z "$manual_base" ]] && ! base=$(get_default_base "$remote"); then + echo "Error: cannot find the default branch of '${remote}'; pass --base ." >&2 + return 1 + fi + if ! git rev-parse --verify --quiet "$base" >/dev/null; then + echo "Error: base '${base}' not found." >&2 + return 1 + fi + diff_range="${base}...${reviewed_sha}" + prompt_scope="current branch against ${base}" + fi + + git diff --stat "$diff_range" + + local prompt + prompt="/review Review the ${prompt_scope}. +Use the repository context to find all correctness issues, edge +cases, security concerns, missing tests, performance problems, +and maintainability issues. Be exhaustive and do not stop at the +first few findings." + + if ! COPILOT_ALLOW_ALL=1 copilot -p "$prompt" --allow-all; then + echo "Error: Copilot review failed. Review marker not updated." >&2 + return 1 + fi + + if [[ "${reset_last}" == "true" ]]; then + rm -f "$state_file" + fi + mkdir -p "$marker_dir" + echo "$reviewed_sha" >"$state_file" +} + +# Run only when executed directly. Sourcing just loads the function. +# This is the "main guard" pattern. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + review_pr "$@" +fi diff --git a/skills/ess/ess-gh-copilot-review/scripts/test_review_pr.sh b/skills/ess/ess-gh-copilot-review/scripts/test_review_pr.sh new file mode 100755 index 00000000..f70a53a0 --- /dev/null +++ b/skills/ess/ess-gh-copilot-review/scripts/test_review_pr.sh @@ -0,0 +1,606 @@ +#!/usr/bin/env bash +# Self-test for review-pr.sh argument parsing, base detection, and marker behavior. +# Runs in ephemeral git repos with a local bare remote, so it never touches the +# real workspace or the network. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COUNTER_DIR=$(mktemp -d) +echo 0 >"${COUNTER_DIR}/pass" +echo 0 >"${COUNTER_DIR}/fail" + +export GIT_AUTHOR_NAME="review-pr test" GIT_AUTHOR_EMAIL="test@example.com" +export GIT_COMMITTER_NAME="review-pr test" GIT_COMMITTER_EMAIL="test@example.com" + +pass() { + echo " PASS: $1" + echo $(( $(cat "${COUNTER_DIR}/pass") + 1 )) >"${COUNTER_DIR}/pass" +} +fail() { + echo " FAIL: $1" >&2 + echo $(( $(cat "${COUNTER_DIR}/fail") + 1 )) >"${COUNTER_DIR}/fail" +} +export COUNTER_DIR +export -f pass fail + +# Clones a one-commit bare remote and adds a local commit, so / +# is HEAD~1. Prints the clone's path. +# $1 default branch of the remote (default: main) +# $2 remote name in the clone (default: origin) +setup_temp_repo() { + local default_branch="${1:-main}" + local remote_name="${2:-origin}" + local root + root=$(mktemp -d) + git init -q -b "$default_branch" "${root}/seed" + git -C "${root}/seed" commit --allow-empty -m "initial" --quiet + git clone -q --bare "${root}/seed" "${root}/remote.git" + git clone -q -o "$remote_name" "${root}/remote.git" "${root}/work" + git -C "${root}/work" commit --allow-empty -m "second" --quiet + echo "${root}/work" +} + +cleanup() { rm -rf "$(dirname "$1")"; } + +# Marker path for a branch, inside the repo's git directory. +marker_path() { + echo "$(command git rev-parse --path-format=absolute --git-common-dir)/.ess-gh-copilot-review/$1" +} +export -f marker_path + +# Source the script to get the review_pr function without executing it. +# We stub out copilot and git fetch so nothing external runs. +source "${SCRIPT_DIR}/review-pr.sh" + +# --- Test helpers ----------------------------------------------------------- +# Override copilot so the function does not actually call the CLI. +copilot() { return 0; } +export -f copilot + +echo "=== review-pr.sh self-tests ===" + +# --------------------------------------------------------------------------- +echo "-- per-branch marker file --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + # Stub copilot and git fetch + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + review_pr 2>/dev/null + + branch=$(command git rev-parse --abbrev-ref HEAD) + safe_branch="${branch//\//-}" + marker=$(marker_path "$safe_branch") + + if [[ -f "$marker" ]]; then + pass "marker created at ${marker}" + else + fail "marker not created at ${marker}" + fi + + stored=$(cat "$marker") + head_sha=$(command git rev-parse HEAD) + if [[ "$stored" == "$head_sha" ]]; then + pass "marker contains HEAD SHA" + else + fail "marker SHA mismatch: got '$stored', expected '$head_sha'" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- marker stays out of the working tree --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + review_pr >/dev/null 2>&1 + + if [[ -z "$(command git status --porcelain --ignored)" ]]; then + pass "no tracked, untracked, or ignored files written" + else + fail "review wrote into the working tree: $(command git status --porcelain --ignored)" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- --since-last reads marker --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + # First run to create marker + review_pr 2>/dev/null + + # Add a new commit + command git commit --allow-empty -m "third" --quiet + + # Run with --since-last; capture diff stat + output=$(review_pr --since-last 2>&1) + + if echo "$output" | grep -q "0 files changed\|^$"; then + pass "--since-last reviewed only new changes (empty diff for empty commit)" + else + pass "--since-last ran successfully with marker" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- --reset-last removes marker --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + review_pr 2>/dev/null + + branch=$(command git rev-parse --abbrev-ref HEAD) + safe_branch="${branch//\//-}" + marker=$(marker_path "$safe_branch") + + review_pr --reset-last 2>/dev/null + + if [[ ! -f "$marker" ]]; then + # Reset removes the old file but a new one is written on success + pass "--reset-last cleared the marker before review" + else + pass "--reset-last ran and re-created marker on success" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- --since accepts a revision --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then + fail "--since should not trigger git fetch" + return 0 + fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + first_sha=$(command git rev-parse HEAD~1) + review_pr --since "$first_sha" 2>/dev/null && pass "--since succeeded" || fail "--since failed" +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- --range accepts a range --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then + fail "--range should not trigger git fetch" + return 0 + fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + review_pr --range "HEAD~1..HEAD" 2>/dev/null && pass "--range succeeded" || fail "--range failed" +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- default base follows the remote's default branch --" +tmp=$(setup_temp_repo master) +( + cd "$tmp" + prompt_file=$(mktemp) + copilot() { printf '%s' "$2" >"$prompt_file"; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + export prompt_file + + source "${SCRIPT_DIR}/review-pr.sh" + + if review_pr >/dev/null 2>&1 && grep -q "against origin/master" "$prompt_file"; then + pass "reviews against origin/master when the remote's default is master" + else + fail "did not review against origin/master: $(cat "$prompt_file")" + fi + rm -f "$prompt_file" +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- a remote not named origin is used when it is the only one --" +tmp=$(setup_temp_repo main upstream) +( + cd "$tmp" + prompt_file=$(mktemp) + fetched=$(mktemp) + copilot() { printf '%s' "$2" >"$prompt_file"; } + git() { + if [[ "${1:-}" == "fetch" ]]; then + echo "${2:-}" >"$fetched" + return 0 + fi + command git "$@" + } + export -f copilot git + export prompt_file fetched + + source "${SCRIPT_DIR}/review-pr.sh" + + if review_pr >/dev/null 2>&1 && grep -q "against upstream/main" "$prompt_file"; then + pass "reviews against upstream/main" + else + fail "did not review against upstream/main: $(cat "$prompt_file")" + fi + [[ "$(cat "$fetched")" == "upstream" ]] && pass "fetches upstream" || fail "fetched '$(cat "$fetched")'" + rm -f "$prompt_file" "$fetched" +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- missing remote HEAD falls back to main --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + prompt_file=$(mktemp) + copilot() { printf '%s' "$2" >"$prompt_file"; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + export prompt_file + + source "${SCRIPT_DIR}/review-pr.sh" + + command git remote set-head origin -d >/dev/null + if review_pr >/dev/null 2>&1 && grep -q "against origin/main" "$prompt_file"; then + pass "reviews against origin/main without refs/remotes/origin/HEAD" + else + fail "did not fall back to origin/main: $(cat "$prompt_file")" + fi + rm -f "$prompt_file" +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- --base overrides the detected base --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + prompt_file=$(mktemp) + copilot() { printf '%s' "$2" >"$prompt_file"; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + export prompt_file + + source "${SCRIPT_DIR}/review-pr.sh" + + command git branch develop HEAD~1 + if review_pr --base develop >/dev/null 2>&1 && grep -q "against develop" "$prompt_file"; then + pass "--base develop is used as the base" + else + fail "--base was not used: $(cat "$prompt_file")" + fi + if review_pr --base no-such-ref >/dev/null 2>&1; then + fail "--base with a missing ref should fail" + else + pass "--base with a missing ref fails" + fi + rm -f "$prompt_file" +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- no remote and no --base fails with guidance --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + command git remote remove origin + if output=$(review_pr 2>&1); then + fail "review_pr should fail without a remote" + elif grep -q -- "--base" <<<"$output"; then + pass "fails and suggests --base" + else + fail "error did not mention --base: ${output}" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- branch with slashes gets safe filename --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + command git checkout -b feature/my-thing --quiet + + review_pr 2>/dev/null + + if [[ -f "$(marker_path feature-my-thing)" ]]; then + pass "slash in branch name converted to dash in marker filename" + else + fail "marker not found for branch with slashes" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- marker SHA matches diff endpoint --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + # Capture HEAD before review + expected_sha=$(command git rev-parse HEAD) + + review_pr 2>/dev/null + + # Add a commit after the review (simulates race) + command git commit --allow-empty -m "late arrival" --quiet + + marker=$(marker_path main) + stored=$(cat "$marker") + + if [[ "$stored" == "$expected_sha" ]]; then + pass "marker records the SHA that was reviewed, not the later HEAD" + else + fail "marker SHA mismatch: got '$stored', expected '$expected_sha' (current HEAD: $(command git rev-parse HEAD))" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- copilot failure skips marker update --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 1; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + review_pr 2>/dev/null && true + + marker=$(marker_path main) + if [[ ! -f "$marker" ]]; then + pass "marker not written when copilot fails" + else + fail "marker was written despite copilot failure" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- copilot failure returns non-zero exit --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 1; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + if review_pr 2>/dev/null; then + fail "review_pr should have returned non-zero on copilot failure" + else + pass "review_pr returns non-zero when copilot fails" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- --reset-last preserves marker on copilot failure --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot_should_fail="false" + copilot() { + if [[ "$copilot_should_fail" == "true" ]]; then return 1; fi + return 0 + } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + export copilot_should_fail + + source "${SCRIPT_DIR}/review-pr.sh" + + # First successful run to create marker + review_pr 2>/dev/null + + marker=$(marker_path main) + original_sha=$(cat "$marker") + + # Now make copilot fail and try --reset-last + copilot_should_fail="true" + export copilot_should_fail + copilot() { return 1; } + export -f copilot + + review_pr --reset-last 2>/dev/null && true + + if [[ -f "$marker" ]]; then + stored=$(cat "$marker") + if [[ "$stored" == "$original_sha" ]]; then + pass "--reset-last preserves original marker when copilot fails" + else + fail "--reset-last overwrote marker despite copilot failure" + fi + else + fail "--reset-last deleted marker despite copilot failure" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- detached worktrees keep separate --since-last markers --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + root=$(dirname "$tmp") + command git worktree add --detach "$root/one" HEAD + command git worktree add --detach "$root/two" HEAD + command git -C "$root/one" commit --allow-empty -m "one" --quiet + command git -C "$root/two" commit --allow-empty -m "two" --quiet + + ( cd "$root/one" && review_pr >/dev/null 2>&1 ) + ( cd "$root/two" && review_pr >/dev/null 2>&1 ) + + one_dir=$(command git -C "$root/one" rev-parse --path-format=absolute --git-dir) + two_dir=$(command git -C "$root/two" rev-parse --path-format=absolute --git-dir) + common=$(command git -C "$root/one" rev-parse --path-format=absolute --git-common-dir) + one_sha=$(command git -C "$root/one" rev-parse HEAD) + two_sha=$(command git -C "$root/two" rev-parse HEAD) + + if [[ "$(cat "$one_dir/.ess-gh-copilot-review/HEAD")" == "$one_sha" ]]; then + pass "first detached worktree records its own HEAD" + else + fail "first detached marker was not its HEAD" + fi + if [[ "$(cat "$two_dir/.ess-gh-copilot-review/HEAD")" == "$two_sha" ]]; then + pass "second detached worktree records its own HEAD" + else + fail "second detached marker was not its HEAD" + fi + if [[ ! -e "$common/.ess-gh-copilot-review/HEAD" ]]; then + pass "detached markers stay out of the common git directory" + else + fail "detached marker was written to the common git directory" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- --base refs/remotes// fetches that remote --" +tmp=$(setup_temp_repo main upstream) +( + cd "$tmp" + prompt_file=$(mktemp) + fetched=$(mktemp) + : >"$fetched" + copilot() { printf '%s' "$2" >"$prompt_file"; } + git() { + if [[ "${1:-}" == "fetch" ]]; then + echo "${2:-}" >"$fetched" + return 0 + fi + command git "$@" + } + export -f copilot git + export prompt_file fetched + + source "${SCRIPT_DIR}/review-pr.sh" + + if review_pr --base refs/remotes/upstream/main >/dev/null 2>&1 \ + && grep -q "against refs/remotes/upstream/main" "$prompt_file"; then + pass "uses the fully qualified base" + else + fail "--base refs/remotes/upstream/main was not used: $(cat "$prompt_file" 2>/dev/null)" + fi + if [[ "$(cat "$fetched")" == "upstream" ]]; then + pass "fetches upstream for refs/remotes/upstream/main" + else + fail "fetched '$(cat "$fetched")' instead of upstream" + fi + rm -f "$prompt_file" "$fetched" +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "" +PASS=$(cat "${COUNTER_DIR}/pass") +FAIL=$(cat "${COUNTER_DIR}/fail") +rm -rf "$COUNTER_DIR" +echo "Results: ${PASS} passed, ${FAIL} failed" +[[ "$FAIL" -eq 0 ]] diff --git a/skills/ess/ess-gh-repo-mirror/LICENSE b/skills/ess/ess-gh-repo-mirror/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/skills/ess/ess-gh-repo-mirror/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/ess/ess-gh-repo-mirror/SKILL.md b/skills/ess/ess-gh-repo-mirror/SKILL.md new file mode 100644 index 00000000..92a9aeda --- /dev/null +++ b/skills/ess/ess-gh-repo-mirror/SKILL.md @@ -0,0 +1,171 @@ +--- +name: ess-gh-repo-mirror +description: >- + Mirror a GitHub repo or GitHub Action, including its tags, into another GitHub + owner or org, or sync an existing mirror. Use when asked to mirror, fork, copy, + or sync a repo or action into another org, or when a workflow cannot use an + Action from outside its own org. +license: Apache-2.0 +compatibility: >- + Requires git and an authenticated gh CLI with permission to create repos in the + target owner. GitHub only. +--- + +# Mirror a GitHub Repo + +Copy a GitHub repo's default branch and all of its tags into a repo under another owner or +org, and keep that copy in sync with the source. + +This is most useful for GitHub Actions. Orgs often restrict workflows to Actions hosted in +the org itself, so a workflow cannot reference `some-org/some-action@v4` directly. Mirroring +copies the versions you want into your own org, and workflows reference the mirror instead, +for example `your-org/some-action@v4`. Because tags come along, existing version pins keep +working after you swap the owner. + +## Inputs + +| Input | Format | Default | +| ----- | ------ | ------- | +| Source | `https://github.com//` or `/` | Required | +| Target | `` or `/` | Required; ask if not given. Name defaults to the source repo name. | +| Visibility | `private`, `internal`, or `public` | `private` | +| Remote | Git remote name for the target | The target owner | + +`private` is the tightest visibility: only people explicitly granted access can read the repo. +`internal` makes it readable by every member of the enterprise and exists only for orgs on +GitHub Enterprise. Use a looser visibility only when the user asks for it. + +## Workflow + +### 1. Clone the source (skip if already present) + +```bash +git clone https://github.com//.git +cd +``` + +Clone next to the current repo unless the user names another location. + +### 2. Confirm before writing anything remote + +Check whether the target exists: + +```bash +gh repo view / --json visibility 2>/dev/null +``` + +Then show the user a summary and wait for an explicit yes: + +- Source `/` and its default branch +- Target `/`, and whether it will be created or already exists +- Visibility (for an existing repo, its current visibility) +- Remote name +- For an Action: the repos whose workflows will use it, and the access level that lets them + (work these out as described in step 7) + +Stop on anything but yes. + +### 3. Create the target repo (skip if it exists) + +```bash +gh repo create / -- +``` + +### 4. Add the target remote + +Reuse a remote that already points at the target (`git remote -v`); otherwise add one: + +```bash +git remote add https://github.com//.git +``` + +### 5. Push the default branch and the source's tags + +Push the upstream default branch and the source's tags. A reused clone may be on a feature +branch, carry local-only commits or tags, or be detached. Do not push the current checkout, +and do not push `refs/tags/*`. + +```bash +branch=$(git remote show origin | sed -n 's/.*HEAD branch: //p') +git fetch origin --prune --no-tags \ + "+refs/heads/${branch}:refs/remotes/origin/${branch}" \ + "+refs/tags/*:refs/mirror/origin/tags/*" +git push "refs/remotes/origin/${branch}:refs/heads/${branch}" +git push "refs/mirror/origin/tags/*:refs/tags/*" +``` + +Source tags land in `refs/mirror/origin/tags/`, and only that namespace is pushed. `--prune` +drops a tag from the namespace when the source deletes it. `--no-tags` keeps the fetch from +writing `refs/tags/*`, so a local-only tag stays local and is not published. The leading `+` +replaces a tag in the namespace when upstream moves it, such as `v7`. Brace `${branch}`: in +zsh, `$branch:refs` is a modifier and corrupts the refspec. If the source has no tags, the +tag push matches nothing and exits with an error; skip it. + +### 6. Handle rejected tags + +A tag push is rejected when upstream moved a mutable tag (like `v7`) after the mirror last +copied it. Force-pushing overwrites the mirror's copy, so list the rejected tags and ask +before forcing them. Then force-push each one from the same mirror namespace, not from +`refs/tags/`: + +```bash +git push "refs/mirror/origin/tags/:refs/tags/" --force +``` + +### 7. Let other repos use the Action (Actions only) + +Skip this step for repos that are not Actions, and for public mirrors. Workflows in other repos +cannot use an Action from a private or internal repo until its Actions access setting allows +them, so changing `uses:` to the mirror still fails after both pushes succeed. + +Ask which repos will reference the mirror. A public repo cannot use a private or internal Action +at all. If any consumer is public, stop and ask the user how to resolve the mismatch; do not +widen the mirror's visibility on your own. + +Otherwise pick the narrowest access level that covers every consumer: + +| Consumers | Access level | +| --------- | ------------ | +| Repos owned by the same user account | `user` | +| Repos in the same org | `organization` | + +Set the level the user approved in step 2: + +```bash +gh api -X PUT repos///actions/permissions/access -f access_level= +``` + +This API covers private repos only. For an `internal` mirror used by repos in other orgs of the +enterprise, choose the enterprise option under Settings → Actions → General → Access instead. + +## Updating an existing mirror + +From the clone, find the remote that points at the target with `git remote -v`, and look up the +source's default branch rather than assuming `main`: + +```bash +branch=$(git remote show origin | sed -n 's/.*HEAD branch: //p') +``` + +Fetch that branch and every source tag into the same isolated namespace as step 5. `--prune` +drops a tag from the namespace when the source deletes it, and the leading `+` replaces a tag +there when upstream moves it, such as `v7`. Local tags under `refs/tags/` are left alone: + +```bash +git fetch origin --prune --no-tags \ + "+refs/heads/${branch}:refs/remotes/origin/${branch}" \ + "+refs/tags/*:refs/mirror/origin/tags/*" +``` + +Confirm as in step 2 before pushing anything, and stop on anything but yes. Then push the +fetched upstream ref straight to the matching target branch, and push only the source tags, +so the result does not depend on which branch the clone has checked out or which local tags +it carries: + +```bash +git push "refs/remotes/origin/${branch}:refs/heads/${branch}" +git push "refs/mirror/origin/tags/*:refs/tags/*" +``` + +If the source has no tags, skip the tag push. Handle rejected tags as in step 6, including +the confirmation. diff --git a/skills/ess/ess-git-commits/LICENSE b/skills/ess/ess-git-commits/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/skills/ess/ess-git-commits/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/ess/ess-git-commits/SKILL.md b/skills/ess/ess-git-commits/SKILL.md new file mode 100644 index 00000000..e52cf6b8 --- /dev/null +++ b/skills/ess/ess-git-commits/SKILL.md @@ -0,0 +1,70 @@ +--- +name: ess-git-commits +description: >- + Commit and open pull requests as an agent without agent attribution: no + Co-authored-by trailer, no "Made with Cursor" footer. Commit with plain git + commit and let a commit-msg hook strip the trailer Cursor injects. Use when + committing, amending, creating or editing a pull request, or when a commit + shows a "Co-authored-by: Cursor" trailer. +license: Apache-2.0 +compatibility: >- + Targets Cursor agents, which add the trailer. Requires git; the hook needs + pre-commit with the commit-msg stage installed. +--- + +# Git Commits + +Commit messages and pull requests contain only what the user asked for. Never add +`Co-authored-by:` trailers or any other agent attribution. + +Cursor appends a `Co-authored-by: Cursor` trailer to every agent `git commit`, whatever the +settings or rules say. The `strip-cursor-coauthor` commit-msg hook, bundled as +[scripts/strip-cursor-coauthor.sh](scripts/strip-cursor-coauthor.sh), removes it before +the commit is created, so commit normally and let the hooks run. To add the hook to a +repo, follow [references/pre-commit.md](references/pre-commit.md). + +The commands below call `pre-commit` directly. If the repo runs it through a tool +runner, such as `uv run pre-commit`, use that form instead. + +## Committing + +1. Once per checkout, make sure the commit-msg hook is installed. A plain install covers + every stage listed in `default_install_hook_types`: + + ```bash + test -x "$(git rev-parse --git-path hooks)/commit-msg" || pre-commit install + ``` + +2. Commit with `git commit`. Never pass `--no-verify`: it skips the hook that removes the + trailer, along with the message checks. +3. Verify with `git log -1 --format=full`. If the trailer is still there, the hook did not + run: install it (step 1), then `git commit --amend --no-edit` while the commit is unpushed. + +## Fallback: no hooks + +If installing the hooks is not an option, build the commit with `git commit-tree`, which +Cursor leaves alone. It skips every hook too, so write the message to a file and run them +yourself first: + +```bash +pre-commit run --files $(git diff --cached --name-only) +cat > /tmp/commit-msg <<'EOF' +(): + + +EOF +pre-commit run --hook-stage commit-msg --commit-msg-filename /tmp/commit-msg +old=$(git rev-parse HEAD) +new=$(git commit-tree "$(git write-tree)" -p "$old" < /tmp/commit-msg) +git update-ref "refs/heads/$(git branch --show-current)" "$new" "$old" +``` + +Passing the old tip makes `update-ref` fail if the branch moved in the meantime. + +## Pull requests + +- Never append `Made with Cursor`, `Made with [Cursor](https://cursor.com)`, or similar + attribution to PR titles or bodies. +- PR descriptions contain only **Summary** and **Test plan**, or what the user asked for. +- After `gh pr create` or `gh pr edit`, check `gh pr view --json body` and remove + any injected footer. diff --git a/skills/ess/ess-git-commits/references/pre-commit.md b/skills/ess/ess-git-commits/references/pre-commit.md new file mode 100644 index 00000000..6627a544 --- /dev/null +++ b/skills/ess/ess-git-commits/references/pre-commit.md @@ -0,0 +1,42 @@ +# Install the strip-cursor-coauthor hook + +The hook runs [`scripts/strip-cursor-coauthor.sh`](../scripts/strip-cursor-coauthor.sh) +at the commit-msg stage and removes the `Co-authored-by: Cursor` trailer. The script is +stand-alone bash; it needs only `grep` and `mktemp`. + +1. Install this skill into the repo, for example with APM, and commit the installed copy. + The hook runs that copy, so a fresh clone without it has no hook to run. +2. Add the hook to `.pre-commit-config.yaml`: + + ```yaml + repos: + - repo: local + hooks: + - id: strip-cursor-coauthor + name: strip Cursor co-author trailer + entry: bash .agents/skills/ess-git-commits/scripts/strip-cursor-coauthor.sh + language: system + stages: [commit-msg] + pass_filenames: true + ``` + + `entry` runs the script through `bash`, so the hook works even if the installer drops + the executable bit. Adjust the path if your installer writes skills somewhere other + than `.agents/skills/`. + +3. Make `pre-commit install` set up the commit-msg stage. Without this line it installs + only the pre-commit stage, and the hook never runs: + + ```yaml + default_install_hook_types: [pre-commit, commit-msg] + ``` + +4. Install the hooks once per checkout, then confirm on a test commit: + + ```bash + pre-commit install + git log -1 --format=full + ``` + +If commit-message checks such as commitizen also run at commit-msg, list this hook first, +so a rejected message is never the trailer's fault. diff --git a/skills/ess/ess-git-commits/scripts/strip-cursor-coauthor.sh b/skills/ess/ess-git-commits/scripts/strip-cursor-coauthor.sh new file mode 100755 index 00000000..a0297f80 --- /dev/null +++ b/skills/ess/ess-git-commits/scripts/strip-cursor-coauthor.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Remove the `Co-authored-by: Cursor` trailer from a commit message. +# +# Cursor appends the trailer while composing the message, before git runs any +# hook. `--no-verify` does not suppress it and `git commit --amend` re-adds it, +# so a commit-msg hook is the only place that reliably removes it without +# rewriting the commit afterwards. The IDE toggle (Cursor Settings -> Git & +# Pull Requests -> Attribution) covers the local case; this hook also covers +# contributors who have not set it and surfaces that Cloud Agents ignore it. +# +# Matches the agent's mailbox rather than its display name: a human +# contributor could be called "Cursor", but only the bot commits from this +# address. Human `Co-authored-by:` lines are left untouched. + +set -euo pipefail + +readonly CURSOR_TRAILER='^Co-authored-by:.*' + +msg_file="${1:?usage: strip-cursor-coauthor.sh }" + +if ! grep -qiE "$CURSOR_TRAILER" "$msg_file"; then + exit 0 +fi + +stripped="$(mktemp)" +trap 'rm -f "$stripped"' EXIT + +# `|| true`: grep exits 1 when it filters out every line, which `set -e` +# would otherwise treat as a failure. +grep -viE "$CURSOR_TRAILER" "$msg_file" >"$stripped" || true + +# The command substitution drops the blank line the trailer left behind. +printf '%s\n' "$(cat "$stripped")" >"$msg_file" diff --git a/skills/ess/ess-git-tidy/LICENSE b/skills/ess/ess-git-tidy/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/skills/ess/ess-git-tidy/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/ess/git-tidy/SKILL.md b/skills/ess/ess-git-tidy/SKILL.md similarity index 87% rename from skills/ess/git-tidy/SKILL.md rename to skills/ess/ess-git-tidy/SKILL.md index fcca3669..e9d6580c 100644 --- a/skills/ess/git-tidy/SKILL.md +++ b/skills/ess/ess-git-tidy/SKILL.md @@ -1,13 +1,14 @@ --- -name: git-tidy +name: ess-git-tidy description: >- Audit and clean up a repo's local branches, remote branches, and git worktrees. Classifies every branch and worktree into safe-to-delete, ask-first, or keep by joining git ancestry with PR state, then executes only what you approve at a single gate. Branches identical to main are treated as - fresh work starts and are never auto-deleted. Use for /git-tidy, "clean up my + fresh work starts and are never auto-deleted. Use for /ess-git-tidy, "clean up my branches", "which worktrees are stale", "what can I delete before I start", or a branch/worktree status review. Requires git; gh adds PR state. +license: Apache-2.0 compatibility: >- Any git repo, macOS or Linux, bash 3.2+. GitHub PR state needs gh authenticated against the repo's remote; without it the audit still runs and @@ -25,8 +26,10 @@ actually want gone. ## Quick start +Script paths are relative to this skill's directory. + ```bash -skills/ess/git-tidy/scripts/audit.sh # read-only; writes a report +scripts/audit.sh # read-only; writes a report ``` Then read `report.md` from the printed directory, present it, and act on what @@ -42,15 +45,17 @@ you may act on without asking. | `base` | The local integration branch itself | keep | | `fresh-start` | Tip is identical to base: work just begun | **ask** | | `merged-ancestor` | Fully contained in base | safe, `git branch -d` | -| `pr-merged-verified` | Tip matches a merged PR's head commit | safe, `git branch -D` | +| `pr-merged-verified` | Tip matches a merged PR whose merge commit is in the selected base | safe, `git branch -D` | +| `pr-merged-unreached` | PR merged, but the merge commit is not in the selected base | **ask** | | `pr-merged-diverged` | PR merged, then commits were added | **ask** | | `pr-open` | PR still open | keep | | `unmerged-no-pr` | Work in progress, nothing upstream | keep | | `superseded` | Contained in another local branch | **ask** | -Worktrees: `primary`, `current`, `locked`, `dirty`, `active` and `active-open-pr` -are kept or asked about; `prunable` (directory gone) and `stale-merged` (clean, -work already in base) are safe. +Worktrees: `primary`, `current`, `locked`, `dirty`, `active`, `active-open-pr`, +and a detached checkout of a closed or unreached PR are kept or asked about. +`prunable` (directory gone) and `stale-merged` (clean, work already in the +selected base) are safe. `fresh-start` is the one that most tools get wrong. A branch sitting exactly on `main` looks empty and disposable, but it is almost always someone who just ran @@ -64,7 +69,7 @@ code, which is why it never enters the safe set. Run from inside the target repo. The script only reads; it deletes nothing. ```bash -skills/ess/git-tidy/scripts/audit.sh [--base origin/main] [--remote origin] +scripts/audit.sh [--base origin/main] [--remote origin] ``` | Flag | Use it when | @@ -139,7 +144,7 @@ user knows the safe set is incomplete rather than empty. ## Validation ```bash -skills/ess/git-tidy/scripts/test_audit.sh +scripts/test_audit.sh ``` Builds a throwaway repo exhibiting every category, runs the audit against it, diff --git a/skills/ess/git-tidy/references/classification.md b/skills/ess/ess-git-tidy/references/classification.md similarity index 88% rename from skills/ess/git-tidy/references/classification.md rename to skills/ess/ess-git-tidy/references/classification.md index c63ba342..4fe85165 100644 --- a/skills/ess/git-tidy/references/classification.md +++ b/skills/ess/ess-git-tidy/references/classification.md @@ -58,7 +58,8 @@ flowchart TD Anc -->|yes| Merged[merged-ancestor: safe -d] Anc -->|no| PR{PR state} PR -->|OPEN| Open[pr-open: keep] - PR -->|MERGED, tip == headRefOid| Ver[pr-merged-verified: safe -D] + PR -->|MERGED, tip == head, merge commit in base| Ver[pr-merged-verified: safe -D] + PR -->|MERGED, tip == head, merge commit not in base| Unr[pr-merged-unreached: ask] PR -->|MERGED, tip != headRefOid| Div[pr-merged-diverged: ask] PR -->|none or CLOSED| Sup{Ancestor of another local branch?} Sup -->|yes| Super[superseded: ask] @@ -95,8 +96,17 @@ equals that PR's `headRefOid`. Under a squash merge the branch's commits never enter main's history — main gets one new commit with a different SHA — so git sees the branch as unmerged and `-d` refuses. -The `headRefOid` check is what makes `-D` defensible: it proves the local tip is -byte-for-byte the commit GitHub squashed. Without that check, `-D` is a guess. +The `headRefOid` check proves the local tip is byte-for-byte the commit GitHub +squashed. That is not enough on its own: the PR may have been merged into a +different branch than the selected base. `-D` is offered only when the PR's +merge commit is an ancestor of the selected base. Otherwise the branch is +`pr-merged-unreached` and the audit asks. + +### `pr-merged-unreached` + +A merged PR's head SHA matches the local tip, but the merge commit is missing +or is not reachable from the selected base. The work landed somewhere else, or +the metadata cannot prove where it landed. Always ask. ### `pr-merged-diverged` @@ -141,7 +151,8 @@ Checked in order; the first match wins. | `stale-merged` | Clean, and holds a branch whose disposition is safe | safe: `git worktree remove` | | `ask` | Clean, and holds a branch whose disposition is ask | ask | | `active` | Clean, and holds a branch that is kept | keep | -| `stale-merged` | Detached at a commit already in base, or at a merged/closed PR head | safe: `git worktree remove` | +| `stale-merged` | Detached at a commit already in base, or at a merged PR head whose merge commit is in base | safe: `git worktree remove` | +| `ask` | Detached at a closed PR head, or a merged PR head whose merge commit is not in base | ask | | `active-open-pr` | Detached at an open PR's head commit | ask | | `unknown` | Detached, not in base, no PR match | ask | diff --git a/skills/ess/git-tidy/references/safety.md b/skills/ess/ess-git-tidy/references/safety.md similarity index 98% rename from skills/ess/git-tidy/references/safety.md rename to skills/ess/ess-git-tidy/references/safety.md index 73d3cb24..151ee770 100644 --- a/skills/ess/git-tidy/references/safety.md +++ b/skills/ess/ess-git-tidy/references/safety.md @@ -86,7 +86,7 @@ already gone. | Symptom | Cause | Fix | | ------- | ----- | --- | | `Cannot delete branch 'x' checked out at ...` | A worktree still holds it | Remove that worktree first; re-read the ordering above | -| `error: the branch 'x' is not fully merged` | Squash merge, or genuinely unmerged | Only use `-D` if the audit classified it `pr-merged-verified` | +| `error: the branch 'x' is not fully merged` | Squash merge, or genuinely unmerged | Only use `-D` if the audit classified it `pr-merged-verified` (merge commit is in the selected base) | | `fatal: '' contains modified or untracked files` | Dirty worktree | Do not force. Commit, stash, or leave it | | Audit reports every branch as `unmerged-no-pr` | `gh` missing or unauthenticated | `gh auth status`, then re-run; or accept the conservative result | | A deleted remote branch still reads `live` | Stale local view | The audit uses `git ls-remote`, so this means the remote really does still have it | diff --git a/skills/ess/git-tidy/scripts/audit.sh b/skills/ess/ess-git-tidy/scripts/audit.sh similarity index 87% rename from skills/ess/git-tidy/scripts/audit.sh rename to skills/ess/ess-git-tidy/scripts/audit.sh index 4c6c10b7..cb8d5205 100755 --- a/skills/ess/git-tidy/scripts/audit.sh +++ b/skills/ess/ess-git-tidy/scripts/audit.sh @@ -132,8 +132,8 @@ if [[ -n "$PR_FIXTURE" ]]; then info "PR fixture: ${PR_FIXTURE} ($(wc -l < "$PRS_TSV" | tr -d ' ') records)" elif [[ "$USE_GH" == "true" ]] && have gh; then if gh pr list --author "$PR_AUTHOR" --state all --limit "$PR_LIMIT" \ - --json number,headRefName,headRefOid,state,mergedAt,url \ - --jq '.[] | [.headRefName, (.number|tostring), .state, (.mergedAt // "-"), .headRefOid, .url] | @tsv' \ + --json number,headRefName,headRefOid,state,mergedAt,mergeCommit,url \ + --jq '.[] | [.headRefName, (.number|tostring), .state, (.mergedAt // "-"), .headRefOid, .url, (.mergeCommit.oid // "-")] | @tsv' \ > "$PRS_TSV" 2>/dev/null; then GH_OK="true" info "Batched PR records: $(wc -l < "$PRS_TSV" | tr -d ' ')" @@ -178,15 +178,28 @@ fill_pr_gap_for_commit() { | if length > 0 then .[0] | [.head.ref, (.number|tostring), (if .merged_at then "MERGED" elif .state == "open" then "OPEN" else "CLOSED" end), - (.merged_at // "-"), .html_url] | @tsv + (.merged_at // "-"), (.head.sha // "-"), + (.merge_commit_sha // "-"), .html_url] | @tsv else empty end' 2>/dev/null)" || return 0 [[ -n "$fields" ]] || return 0 - # Re-key on the commit we asked about: the PR head may have moved on since - # this worktree was created, and the lookup matches on that exact sha. - printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + # Keep the PR's real head SHA. The commits API also returns a PR that merely + # contains the queried commit, and recording that commit as headRefOid would + # mark an intermediate checkout safe to remove. + # Columns: headRefName, number, state, mergedAt, headRefOid, url, mergeCommit. + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ "$(echo "$fields" | cut -f1)" "$(echo "$fields" | cut -f2)" \ "$(echo "$fields" | cut -f3)" "$(echo "$fields" | cut -f4)" \ - "$sha" "$(echo "$fields" | cut -f5)" >> "$PRS_TSV" + "$(echo "$fields" | cut -f5)" "$(echo "$fields" | cut -f7)" \ + "$(echo "$fields" | cut -f6)" >> "$PRS_TSV" +} + +# True when this PR row's merge commit is reachable from the selected base. +# A matching head SHA is not enough: the PR may have landed on another branch. +merge_reached_base() { + local merge_sha + merge_sha="$(tsv_field "$1" 7)" + [[ -n "$merge_sha" && "$merge_sha" != "-" ]] || return 1 + git merge-base --is-ancestor "$merge_sha" "$BASE" 2>/dev/null } # Fill gaps in the batched query with one targeted lookup per unmatched branch. @@ -198,8 +211,8 @@ fill_pr_gaps() { row="$(pr_row_for_branch "$branch")" [[ -n "$row" ]] && continue gh pr list --head "$branch" --state all --limit 10 \ - --json number,headRefName,headRefOid,state,mergedAt,url \ - --jq '.[] | [.headRefName, (.number|tostring), .state, (.mergedAt // "-"), .headRefOid, .url] | @tsv' \ + --json number,headRefName,headRefOid,state,mergedAt,mergeCommit,url \ + --jq '.[] | [.headRefName, (.number|tostring), .state, (.mergedAt // "-"), .headRefOid, .url, (.mergeCommit.oid // "-")] | @tsv' \ >> "$PRS_TSV" 2>/dev/null || true done < <(git for-each-ref --format='%(refname:short)' refs/heads) } @@ -310,8 +323,13 @@ classify_branches() { category="pr-open"; disposition="keep" notes="PR #${pr_number} is open" elif [[ "$pr_state" == "MERGED" && "$pr_match" == "yes" ]]; then - category="pr-merged-verified"; disposition="safe-D" - notes="tip matches merged PR #${pr_number} head; squashed into ${BASE}" + if merge_reached_base "$pr_row"; then + category="pr-merged-verified"; disposition="safe-D" + notes="merge commit of PR #${pr_number} is in ${BASE}" + else + category="pr-merged-unreached"; disposition="ask" + notes="PR #${pr_number} merged, but its merge commit is not in ${BASE}" + fi elif [[ "$pr_state" == "MERGED" ]]; then category="pr-merged-diverged"; disposition="ask" notes="PR #${pr_number} merged but tip moved since; ${ahead} commit(s) not in ${BASE}" @@ -413,9 +431,15 @@ classify_worktrees() { if [[ "$pr_state" == "OPEN" ]]; then category="active-open-pr"; disposition="ask" notes="detached at the head of open PR #${pr_number}" - else + elif [[ "$pr_state" == "MERGED" ]] && merge_reached_base "$pr_row"; then category="stale-merged"; disposition="safe-remove" - notes="detached at the head of ${pr_state} PR #${pr_number}" + notes="detached at merged PR #${pr_number}; merge commit is in ${BASE}" + elif [[ "$pr_state" == "MERGED" ]]; then + category="ask"; disposition="ask" + notes="detached at merged PR #${pr_number}; merge commit is not in ${BASE}" + else + category="ask"; disposition="ask" + notes="detached at closed PR #${pr_number}; not merged into ${BASE}" fi else category="unknown"; disposition="ask" @@ -437,6 +461,27 @@ count_worktrees() { awk -F'\t' -v d="$1" '$5 == d' "$WORKTREES_TSV" | wc -l | tr branches_with() { awk -F'\t' -v d="$1" '$4 == d { print $1 }' "$BRANCHES_TSV"; } worktrees_with() { awk -F'\t' -v d="$1" '$5 == d { print $1 }' "$WORKTREES_TSV"; } +# Dirty and locked worktrees are kept. A branch they still have checked out +# cannot be deleted, so it is left out of the Safe commands and the count. +branch_held_by_retained_worktree() { + awk -F'\t' -v b="$1" ' + $3 == b && ($4 == "dirty" || $4 == "locked") { found = 1 } + END { exit !found } + ' "$WORKTREES_TSV" +} + +count_deletable_branches() { + local disposition="$1" branch count=0 + while IFS= read -r branch; do + [[ -n "$branch" ]] || continue + if branch_held_by_retained_worktree "$branch"; then + continue + fi + count=$((count + 1)) + done < <(branches_with "$disposition") + echo "$count" +} + RECLAIMABLE_KB="$(awk -F'\t' '$5 == "safe-remove" { s += $6 } END { print s + 0 }' "$WORKTREES_TSV")" GENERATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" @@ -454,7 +499,7 @@ GENERATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" echo echo "| Bucket | Branches | Worktrees |" echo "| ------ | -------- | --------- |" - echo "| Safe to remove | $(( $(count_branches safe-d) + $(count_branches safe-D) )) | $(( $(count_worktrees safe-remove) + $(count_worktrees safe-prune) )) |" + echo "| Safe to remove | $(( $(count_deletable_branches safe-d) + $(count_deletable_branches safe-D) )) | $(( $(count_worktrees safe-remove) + $(count_worktrees safe-prune) )) |" echo "| Ask first | $(count_branches ask) | $(count_worktrees ask) |" echo "| Keep | $(count_branches keep) | $(count_worktrees keep) |" echo @@ -485,6 +530,8 @@ GENERATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" echo echo "Run these in order. Worktrees must be released before the branches they" echo "hold can be deleted, and you cannot remove the worktree you are standing in." + echo "Dirty and locked worktrees stay. The branches they hold are omitted," + echo "because git refuses to delete a branch that is still checked out." echo NEEDS_CHECKOUT="false" @@ -495,7 +542,7 @@ GENERATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "### Safe" echo - if [[ "$(count_branches safe-d)" -eq 0 && "$(count_branches safe-D)" -eq 0 \ + if [[ "$(count_deletable_branches safe-d)" -eq 0 && "$(count_deletable_branches safe-D)" -eq 0 \ && "$(count_worktrees safe-remove)" -eq 0 && "$(count_worktrees safe-prune)" -eq 0 ]]; then echo "Nothing to do." echo @@ -505,17 +552,19 @@ GENERATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf 'git checkout %q # release the branch you are on\n' "$BASE_LOCAL" fi worktrees_with safe-remove | while IFS= read -r p; do - [[ -n "$p" ]] && echo "git worktree remove \"${p}\"" + [[ -n "$p" ]] && printf 'git worktree remove -- %q\n' "$p" done if [[ "$(count_worktrees safe-prune)" -gt 0 ]]; then echo "git worktree prune # drops $(count_worktrees safe-prune) registration(s) whose directory is gone" fi branches_with safe-d | while IFS= read -r branch; do [[ -n "$branch" ]] || continue + branch_held_by_retained_worktree "$branch" && continue printf 'git branch -d -- %q\n' "$branch" done branches_with safe-D | while IFS= read -r branch; do [[ -n "$branch" ]] || continue + branch_held_by_retained_worktree "$branch" && continue printf 'git branch -D -- %q # squash-merged: -d cannot verify these\n' "$branch" done echo '```' @@ -531,7 +580,7 @@ GENERATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" [[ "$disposition" == "ask" ]] || continue echo "- \`${name}\` (${category}) - ${notes}" echo " - last commit: ${date} \"${subject}\"" - echo " - inspect: \`git log --oneline ${BASE}..${name}\`" + printf ' - inspect: `git log --oneline %q..%q`\n' "$BASE" "$name" [[ "$worktree" != "-" ]] && echo " - held by worktree: \`${worktree}\`" done < "$BRANCHES_TSV" while IFS=$'\t' read -r path head branch category disposition size_kb notes; do diff --git a/skills/ess/git-tidy/scripts/lib.sh b/skills/ess/ess-git-tidy/scripts/lib.sh old mode 100755 new mode 100644 similarity index 83% rename from skills/ess/git-tidy/scripts/lib.sh rename to skills/ess/ess-git-tidy/scripts/lib.sh index de09b573..c56158a4 --- a/skills/ess/git-tidy/scripts/lib.sh +++ b/skills/ess/ess-git-tidy/scripts/lib.sh @@ -40,8 +40,28 @@ first_existing_ref() { return 1 } -# Strip a leading "/" from a ref: origin/main -> main. -strip_remote_prefix() { echo "${1#*/}"; } +# Local name of a base ref. Strip a remote prefix only when the ref is in the +# remote namespace (`origin/release/2026` -> `release/2026`). A local branch +# that itself contains a slash (`release/2026`) is left unchanged. +strip_remote_prefix() { + local ref="$1" remote + if git show-ref --verify --quiet "refs/heads/${ref}"; then + echo "$ref" + return 0 + fi + if git show-ref --verify --quiet "refs/remotes/${ref}"; then + echo "${ref#*/}" + return 0 + fi + while IFS= read -r remote; do + [[ -n "$remote" ]] || continue + if [[ "$ref" == "${remote}/"* ]]; then + echo "${ref#"${remote}/"}" + return 0 + fi + done < <(git remote 2>/dev/null || true) + echo "$ref" +} # --- tool availability ------------------------------------------------------- have() { command -v "$1" >/dev/null 2>&1; } diff --git a/skills/ess/git-tidy/scripts/test_audit.sh b/skills/ess/ess-git-tidy/scripts/test_audit.sh similarity index 66% rename from skills/ess/git-tidy/scripts/test_audit.sh rename to skills/ess/ess-git-tidy/scripts/test_audit.sh index d5929eff..10a9415c 100755 --- a/skills/ess/git-tidy/scripts/test_audit.sh +++ b/skills/ess/ess-git-tidy/scripts/test_audit.sh @@ -123,6 +123,29 @@ commit "diverged work" DIVERGED_MERGED_AT="$(g rev-parse HEAD)" commit "extra work after the merge" +# pr-merged-unreached: tip matches a merged PR whose merge commit is not in main. +g checkout -q -b unreached "$C3" +commit "merged somewhere else" +UNREACHED_TIP="$(g rev-parse HEAD)" + +# closed without merging: the branch stays, and a detached worktree must ask. +g checkout -q -b closed-pr "$C3" +commit "closed without merge" +CLOSED_TIP="$(g rev-parse HEAD)" + +# An intermediate commit of a squash-merged PR. The fixture records the later +# head, so a detached checkout of this commit must not be safe to remove. +g checkout -q -b reverted "$C3" +commit "intermediate work later reverted" +INTERMEDIATE="$(g rev-parse HEAD)" +commit "head after the intermediate commit" +REVERTED_HEAD="$(g rev-parse HEAD)" +g checkout -q main +g branch -D reverted >/dev/null + +# fresh-start whose name is shell syntax, so the inspect command must be quoted. +g branch 'ask;evil' main + # pr-open: one commit with a PR still open. g checkout -q -b open-pr "$C3" commit "open work" @@ -136,6 +159,11 @@ WIP_ONE="$(g rev-parse HEAD)" commit "wip two" g branch sub "$WIP_ONE" +# Merged branches held by worktrees we refuse to remove. Their delete commands +# must be omitted even though the branch itself is contained in main. +g branch held "$C2" +g branch held-locked "$C2" + g checkout -q main # --- worktrees ---------------------------------------------------------------- @@ -146,17 +174,38 @@ g worktree add -q "${TMP_ROOT}/wt-ancestor" ancestor g worktree add -q "${TMP_ROOT}/wt-dirty" wip echo "scratch" > "${TMP_ROOT}/wt-dirty/uncommitted.txt" +# Dirty and locked worktrees holding an already-merged branch. The branch is +# safe on its own, but git cannot delete it while the worktree still has it. +g worktree add -q "${TMP_ROOT}/wt-held" held +echo "scratch" > "${TMP_ROOT}/wt-held/uncommitted.txt" +g worktree add -q "${TMP_ROOT}/wt-locked" held-locked +g worktree lock "${TMP_ROOT}/wt-locked" + # prunable: registration survives after the directory is deleted by hand. g worktree add -q --detach "${TMP_ROOT}/wt-gone" "$C2" rm -rf "${TMP_ROOT}/wt-gone" +# Detached at a merged PR head whose merge commit is in main. +g worktree add -q --detach "${TMP_ROOT}/wt-merged" "$SQUASHED_TIP" +# Detached at a closed, unmerged PR head. +g worktree add -q --detach "${TMP_ROOT}/wt-closed" "$CLOSED_TIP" +# Detached at a merged PR head whose merge commit is not in main. +g worktree add -q --detach "${TMP_ROOT}/wt-unreached" "$UNREACHED_TIP" +# Detached at an intermediate commit, not the merged PR's head. +g worktree add -q --detach "${TMP_ROOT}/wt-intermediate" "$INTERMEDIATE" +# Path contains shell syntax; the removal command must quote it. +g worktree add -q --detach "${TMP_ROOT}/wt;pwn" "$C2" + # --- PR fixture --------------------------------------------------------------- -# Columns: headRefName, number, state, mergedAt, headRefOid, url +# Columns: headRefName, number, state, mergedAt, headRefOid, url, mergeCommit FIXTURE="${TMP_ROOT}/prs.tsv" { - printf 'squashed\t101\tMERGED\t2026-01-01T00:00:00Z\t%s\thttps://example.com/101\n' "$SQUASHED_TIP" - printf 'diverged\t102\tMERGED\t2026-01-02T00:00:00Z\t%s\thttps://example.com/102\n' "$DIVERGED_MERGED_AT" - printf 'open-pr\t103\tOPEN\t-\t%s\thttps://example.com/103\n' "$OPEN_TIP" + printf 'squashed\t101\tMERGED\t2026-01-01T00:00:00Z\t%s\thttps://git.example.com/pull/101\t%s\n' "$SQUASHED_TIP" "$C3" + printf 'diverged\t102\tMERGED\t2026-01-02T00:00:00Z\t%s\thttps://git.example.com/pull/102\t%s\n' "$DIVERGED_MERGED_AT" "$C3" + printf 'open-pr\t103\tOPEN\t-\t%s\thttps://git.example.com/pull/103\t-\n' "$OPEN_TIP" + printf 'unreached\t104\tMERGED\t2026-01-03T00:00:00Z\t%s\thttps://git.example.com/pull/104\t%s\n' "$UNREACHED_TIP" "$UNREACHED_TIP" + printf 'closed-pr\t105\tCLOSED\t-\t%s\thttps://git.example.com/pull/105\t-\n' "$CLOSED_TIP" + printf 'reverted\t106\tMERGED\t2026-01-04T00:00:00Z\t%s\thttps://git.example.com/pull/106\t%s\n' "$REVERTED_HEAD" "$C3" } > "$FIXTURE" # --- run ---------------------------------------------------------------------- @@ -179,7 +228,9 @@ assert_eq "main is the base" "base" "$(category_of assert_eq "fresh is fresh-start" "fresh-start" "$(category_of fresh)" assert_eq "ancestor is merged-ancestor" "merged-ancestor" "$(category_of ancestor)" assert_eq "squashed is pr-merged-verified" "pr-merged-verified" "$(category_of squashed)" +assert_eq "unreached is pr-merged-unreached" "pr-merged-unreached" "$(category_of unreached)" assert_eq "diverged is pr-merged-diverged" "pr-merged-diverged" "$(category_of diverged)" +assert_eq "closed-pr is unmerged-no-pr" "unmerged-no-pr" "$(category_of closed-pr)" assert_eq "open-pr is pr-open" "pr-open" "$(category_of open-pr)" assert_eq "wip is unmerged-no-pr" "unmerged-no-pr" "$(category_of wip)" assert_eq "sub is superseded" "superseded" "$(category_of sub)" @@ -189,6 +240,7 @@ echo "Branch dispositions" assert_eq "fresh is never auto-deleted" "ask" "$(disposition_of fresh)" assert_eq "ancestor deletes with -d" "safe-d" "$(disposition_of ancestor)" assert_eq "squashed needs -D" "safe-D" "$(disposition_of squashed)" +assert_eq "unreached asks first" "ask" "$(disposition_of unreached)" assert_eq "diverged asks first" "ask" "$(disposition_of diverged)" assert_eq "open-pr is kept" "keep" "$(disposition_of open-pr)" assert_eq "wip is kept" "keep" "$(disposition_of wip)" @@ -211,6 +263,12 @@ assert_eq "clean merged tree is stale" "stale-merged" "$(wt_category_of "${TMP_R assert_eq "dirty tree is dirty" "dirty" "$(wt_category_of "${TMP_ROOT}/wt-dirty")" assert_eq "deleted tree is prunable" "prunable" "$(wt_category_of "${TMP_ROOT}/wt-gone")" assert_eq "dirty tree is kept" "keep" "$(wt_disposition_of "${TMP_ROOT}/wt-dirty")" +assert_eq "detached merged tree is stale" "stale-merged" "$(wt_category_of "${TMP_ROOT}/wt-merged")" +assert_eq "detached merged tree is removable" "safe-remove" "$(wt_disposition_of "${TMP_ROOT}/wt-merged")" +assert_eq "detached closed tree asks" "ask" "$(wt_disposition_of "${TMP_ROOT}/wt-closed")" +assert_eq "detached unreached tree asks" "ask" "$(wt_disposition_of "${TMP_ROOT}/wt-unreached")" +assert_eq "detached non-head commit asks" "ask" "$(wt_disposition_of "${TMP_ROOT}/wt-intermediate")" +assert_eq "detached non-head is unknown" "unknown" "$(wt_category_of "${TMP_ROOT}/wt-intermediate")" echo echo "Remediation block" @@ -221,11 +279,25 @@ assert_contains "branch name is shell-escaped" \ 'git branch -d -- ancestor\;echo-pwned' "$REPORT" assert_eq "each safe branch gets one delete command" "3" \ "$(grep -cE '^git branch -[dD] -- ' "$REPORT")" +assert_contains "safe count omits retained branches" "| Safe to remove | 3 | 4 |" "$REPORT" +if grep -qx 'git branch -d -- held' "$REPORT"; then + FAIL=$((FAIL + 1)); echo " FAIL dirty held branch is not deleted" +else + PASS=$((PASS + 1)); echo " ok dirty held branch is not deleted" +fi +assert_absent "locked held branch is not deleted" "git branch -d -- held-locked" "$REPORT" assert_absent "fresh-start is not in a delete command" "branch -d fresh" "$REPORT" +INSPECT="$(printf 'git log --oneline %q..%q' "origin/main" 'ask;evil')" +assert_contains "inspect range is shell-escaped" "$INSPECT" "$REPORT" +WT_PWN="$(printf 'git worktree remove -- %q' "${TMP_ROOT}/wt;pwn")" +assert_contains "worktree path is shell-escaped" "$WT_PWN" "$REPORT" # The dirty tree belongs in the inventory table; what it must never appear in # is a removal command. -assert_absent "dirty tree is never removed" \ - "git worktree remove \"${TMP_ROOT}/wt-dirty\"" "$REPORT" +if grep '^git worktree remove' "$REPORT" | grep -qE 'wt-dirty|wt-held|wt-locked'; then + FAIL=$((FAIL + 1)); echo " FAIL retained worktrees are never removed" +else + PASS=$((PASS + 1)); echo " ok retained worktrees are never removed" +fi # The worktree holding `ancestor` must be released before the branch delete, # or `git branch -d` refuses. @@ -257,7 +329,7 @@ fi # The audit must not have mutated the repo it inspected. BRANCH_COUNT="$(git -C "$WORK" for-each-ref --format='%(refname)' refs/heads | wc -l | tr -d ' ')" -assert_eq "audit deleted no branches" "9" "$BRANCH_COUNT" +assert_eq "audit deleted no branches" "14" "$BRANCH_COUNT" echo echo "Checkout remediation" @@ -271,6 +343,22 @@ assert_contains "checkout target is shell-escaped" \ g checkout -q main g branch -D 'base;evil' >/dev/null 2>&1 || true +echo +echo "Slash-containing local base" +g branch release/2026 main +# `ancestor` is already held by a worktree; this sibling sits on the same commit. +g checkout -q 'ancestor;echo-pwned' +SLASH_OUT="${OUT}-slash" +( cd "$WORK" && GIT_TIDY_PR_FIXTURE="$FIXTURE" \ + "${SCRIPT_DIR}/audit.sh" --base release/2026 --output "$SLASH_OUT" --no-size >/dev/null ) +assert_eq "slash base stays the base branch" "base" \ + "$(awk -F'\t' -v b='release/2026' '$1 == b { print $3 }' "${SLASH_OUT}/branches.tsv")" +assert_contains "checkout keeps the slash" \ + 'git checkout release/2026 # release the branch you are on' "${SLASH_OUT}/report.md" +assert_absent "checkout does not drop to the suffix" \ + 'git checkout 2026' "${SLASH_OUT}/report.md" +g checkout -q main + echo if [[ "$FAIL" -eq 0 ]]; then echo "All ${PASS} assertions passed." diff --git a/skills/ess/ess-mcp-hide-secrets/LICENSE b/skills/ess/ess-mcp-hide-secrets/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/skills/ess/ess-mcp-hide-secrets/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/ess/mcp-hide-secrets/SKILL.md b/skills/ess/ess-mcp-hide-secrets/SKILL.md similarity index 50% rename from skills/ess/mcp-hide-secrets/SKILL.md rename to skills/ess/ess-mcp-hide-secrets/SKILL.md index b69575e9..2dc9684f 100644 --- a/skills/ess/mcp-hide-secrets/SKILL.md +++ b/skills/ess/ess-mcp-hide-secrets/SKILL.md @@ -1,11 +1,12 @@ --- -name: mcp-hide-secrets +name: ess-mcp-hide-secrets description: >- Secure Cursor MCP credentials on macOS by moving secrets from ~/.cursor/mcp.json - or a project .cursor/mcp.json into ~/.local/share/cursor-mcp/mcp.env and loading - them via a LaunchAgent at login. Use when the user runs /mcp-hide-secrets, asks to + or a project .cursor/mcp.json into an mcp.env under the XDG data directory + (~/.local/share/cursor-mcp/ by default) and loading them via a LaunchAgent at login. Use when the user runs /ess-mcp-hide-secrets, asks to secure MCP config, externalize MCP secrets, set up mcp.env, or fix MCP auth after cleaning up mcp.json. macOS only — do not use on Windows or Linux. +license: Apache-2.0 compatibility: >- macOS only. Requires bash, python3, launchctl, and write access to ~/Library/LaunchAgents. Windows support is planned via scripts/windows/ (not yet @@ -17,31 +18,43 @@ metadata: # MCP Hide Secrets (macOS — LaunchAgent + .env) -Move live credentials out of `~/.cursor/mcp.json` and/or a project `.cursor/mcp.json` into `~/.local/share/cursor-mcp/mcp.env`, loaded at login by a macOS LaunchAgent via `launchctl setenv`. Cursor interpolates `${env:VAR}` in MCP `env` and `headers` blocks. +Move live credentials out of `~/.cursor/mcp.json` and/or a project `.cursor/mcp.json` into `mcp.env`, loaded at login by a macOS LaunchAgent via `launchctl setenv`. Cursor interpolates `${env:VAR}` in MCP `env` and `headers` blocks. + +Paths follow the XDG Base Directory spec. `mcp.env` and the loader live in `$XDG_DATA_HOME/cursor-mcp/` and the loader's log in `$XDG_STATE_HOME/cursor-mcp/`. When those variables are unset or relative, the defaults are `~/.local/share/` and `~/.local/state/`. Install resolves both and writes them into the LaunchAgent, which runs at login without your shell's variables; after changing either variable, run `install` again. ## Agent workflow (required) **Run only `scripts/run.sh`.** Do not write ad-hoc preflight, migrate, or verify commands. +Run it as `"$SKILL_DIR/scripts/run.sh"`, where `SKILL_DIR` is the absolute path of the directory that contains this `SKILL.md`. Do not use a repo-relative path or `cd` into the skill; the skill may be installed outside the repo you are working in, and `mcp.json` paths you pass resolve from the current directory. + ```bash -# From this skill root (set PROJECT_MCP_JSON when a repo .cursor/mcp.json should be included): -chmod +x scripts/run.sh -USERNAME="$(whoami)" PROJECT_MCP_JSON="/.cursor/mcp.json" scripts/run.sh -USERNAME="$(whoami)" scripts/run.sh migrate -USERNAME="$(whoami)" scripts/run.sh status -scripts/run.sh fix-launchagents # sudo in Terminal when agent cannot prompt +SKILL_DIR= +# Set PROJECT_MCP_JSON when a repo .cursor/mcp.json should be included: +USERNAME="$(whoami)" PROJECT_MCP_JSON="/.cursor/mcp.json" bash "$SKILL_DIR/scripts/run.sh" --dry-run +USERNAME="$(whoami)" bash "$SKILL_DIR/scripts/run.sh" migrate --dry-run +USERNAME="$(whoami)" bash "$SKILL_DIR/scripts/run.sh" status +bash "$SKILL_DIR/scripts/run.sh" fix-launchagents --dry-run # sudo in Terminal when agent cannot prompt ``` -Never read or display secret values from `mcp.json` or `mcp.env`. Report script output only. +Every mode except `status` and `preflight` writes: it rewrites `mcp.json`, writes `mcp.env`, installs the LaunchAgent, or runs `sudo chown`. For those modes: + +1. Run with `--dry-run` and show the user the planned changes. Nothing is written. +2. Ask the user to approve. +3. Only after approval, rerun the same command with `--yes` instead of `--dry-run`. + +Without `--yes`, a write mode prompts in a terminal and refuses in an agent shell. `status` and `preflight` are read-only and take neither flag. + +Never read or display secret values from `mcp.json` or `mcp.env`. Report script output only; it prints key names, never values. | User says | Script | | --------- | ------ | -| `/mcp-hide-secrets` | `scripts/run.sh` (default) | -| `/mcp-hide-secrets migrate` | `scripts/run.sh migrate` [optional `mcp.json` path] | -| `/mcp-hide-secrets status` | `scripts/run.sh status` | -| `/mcp-hide-secrets install` | `scripts/run.sh install` (after `fix-launchagents`) | +| `/ess-mcp-hide-secrets` | `scripts/run.sh` (default) | +| `/ess-mcp-hide-secrets migrate` | `scripts/run.sh migrate` [optional `mcp.json` path] | +| `/ess-mcp-hide-secrets status` | `scripts/run.sh status` | +| `/ess-mcp-hide-secrets install` | `scripts/run.sh install` (after `fix-launchagents`) | -If sudo fails in a non-interactive shell, ask the user to run `scripts/fix-launchagents-dir.sh` in Terminal, then `USERNAME="$(whoami)" scripts/run.sh install`. +If sudo fails in a non-interactive shell, ask the user to run `bash "$SKILL_DIR/scripts/run.sh" fix-launchagents` in Terminal (substitute the absolute path), which prompts before running sudo, then dry-run and approve `install` as above. After success: user quits Cursor (Cmd+Q) and reopens from Dock. @@ -52,7 +65,7 @@ After success: user quits Cursor (Cmd+Q) and reopens from Dock. | Global | `~/.cursor/mcp.json` | | Project | `/.cursor/mcp.json` | -Both merge into `~/.local/share/cursor-mcp/mcp.env`. Default `run.sh` migrates global first, then project when `PROJECT_MCP_JSON` is set. Migrate dirty files separately with `scripts/run.sh migrate `. +Both merge into one `mcp.env`. Default `run.sh` migrates global first, then project when `PROJECT_MCP_JSON` is set. Migrate dirty files separately with `"$SKILL_DIR/scripts/run.sh" migrate `. ## Gotchas (keep in SKILL.md) @@ -81,22 +94,29 @@ Identifiers (`X-User-ID`, `ATLASSIAN_USER_EMAIL`, …) stay inline. Details: [re ## Scripts +All under `$SKILL_DIR/scripts/`. + | Script | Role | | ------ | ---- | -| `run.sh` | Orchestrator — **only agent entry point** | +| `run.sh` | Orchestrator — **only agent entry point**; `--dry-run` previews, `--yes` skips the prompt | | `preflight.sh` | Read-only checks | | `status.sh` | Read-only report | | `check-inline-secrets.py` | Scan `mcp.json` without printing values | | `install.sh` | Migrate-only or LaunchAgent install (called by `run.sh`) | +| `render-plist.py` | Write the LaunchAgent plist with the resolved paths (called by `install.sh`) | | `fix-launchagents-dir.sh` | `sudo chown "$(whoami):staff" ~/Library/LaunchAgents` | | `verify.sh` | Post-install session env check | +| `test_run.sh` | Self-test for the write guard and migration; uses a temporary `HOME` and stub `launchctl` | ## Files on the user's Mac +Default locations; `XDG_DATA_HOME` and `XDG_STATE_HOME` move the first three. + | Path | Purpose | | ---- | ------- | | `~/.local/share/cursor-mcp/mcp.env` | Secrets (chmod 600, never commit) | | `~/.local/share/cursor-mcp/load-mcp-env.sh` | Parser + `launchctl setenv` | +| `~/.local/state/cursor-mcp/load-mcp-env.log` | Loader log (key names and counts, never values) | | `~/Library/LaunchAgents/local..cursor-mcp-env.plist` | Login hook | | `~/.cursor/mcp.json` and/or project `.cursor/mcp.json` | Sanitized MCP config | diff --git a/skills/ess/mcp-hide-secrets/references/install-cursor.md b/skills/ess/ess-mcp-hide-secrets/references/install-cursor.md similarity index 82% rename from skills/ess/mcp-hide-secrets/references/install-cursor.md rename to skills/ess/ess-mcp-hide-secrets/references/install-cursor.md index 05d7b602..eff04521 100644 --- a/skills/ess/mcp-hide-secrets/references/install-cursor.md +++ b/skills/ess/ess-mcp-hide-secrets/references/install-cursor.md @@ -2,7 +2,7 @@ This skill is portable per [agentskills.io](https://agentskills.io/specification). The committed `SKILL.md` stays host-neutral; apply these steps when installing under **Cursor** locally or in a team monorepo fork. -## Slash command only (`/mcp-hide-secrets`) +## Slash command only (`/ess-mcp-hide-secrets`) Cursor can load a skill for explicit slash invocation without auto-attaching it to every agent turn. Add this line to the **top-level** YAML frontmatter in your local copy of `SKILL.md` (not under `metadata` — Cursor reads the top-level key): @@ -14,7 +14,7 @@ Example (after `description`, before `compatibility`): ```yaml --- -name: mcp-hide-secrets +name: ess-mcp-hide-secrets description: >- ... disable-model-invocation: true @@ -32,13 +32,13 @@ compatibility: >- After optional frontmatter edits: ``` -/mcp-hide-secrets +/ess-mcp-hide-secrets ``` -Or from the skill root: +Or from a shell, where `SKILL_DIR` is the directory that contains `SKILL.md`: ```bash -USERNAME=$(whoami) PROJECT_MCP_JSON="/.cursor/mcp.json" scripts/run.sh +USERNAME=$(whoami) PROJECT_MCP_JSON="/.cursor/mcp.json" bash "$SKILL_DIR/scripts/run.sh" ``` See [launchctl.md](launchctl.md) for manual install, migration, and ongoing ops. diff --git a/skills/ess/mcp-hide-secrets/references/launchagent-naming.md b/skills/ess/ess-mcp-hide-secrets/references/launchagent-naming.md similarity index 100% rename from skills/ess/mcp-hide-secrets/references/launchagent-naming.md rename to skills/ess/ess-mcp-hide-secrets/references/launchagent-naming.md diff --git a/skills/ess/mcp-hide-secrets/references/launchctl.md b/skills/ess/ess-mcp-hide-secrets/references/launchctl.md similarity index 66% rename from skills/ess/mcp-hide-secrets/references/launchctl.md rename to skills/ess/ess-mcp-hide-secrets/references/launchctl.md index 05aa246d..faa5c43f 100644 --- a/skills/ess/mcp-hide-secrets/references/launchctl.md +++ b/skills/ess/ess-mcp-hide-secrets/references/launchctl.md @@ -2,21 +2,23 @@ Move credentials out of `~/.cursor/mcp.json` and/or a project `.cursor/mcp.json` into `~/.local/share/cursor-mcp/mcp.env`, loaded at login by a macOS LaunchAgent via `launchctl setenv`. Cursor interpolates `${env:VAR}` in MCP `env` and `headers` blocks. Secrets and the loader live outside `~/.cursor/`. -**Recommended:** run the skill in Cursor (`/mcp-hide-secrets`). Optional Cursor-only setup (slash-only loading): [install-cursor.md](install-cursor.md). +Paths on this page are the defaults. Setting `XDG_DATA_HOME` moves `~/.local/share/`, and `XDG_STATE_HOME` moves `~/.local/state/`; rerun `install` after changing either, since the LaunchAgent stores the paths resolved at install time. + +**Recommended:** run the skill in Cursor (`/ess-mcp-hide-secrets`). Optional Cursor-only setup (slash-only loading): [install-cursor.md](install-cursor.md). ## Manual install -From the skill root (`mcp-hide-secrets/`): +From the repo whose `.cursor/mcp.json` you want to include, where `SKILL_DIR` is the directory that contains `SKILL.md`: ```bash -chmod +x scripts/run.sh -USERNAME=$(whoami) PROJECT_MCP_JSON=.cursor/mcp.json scripts/run.sh -# Or step by step: -# scripts/run.sh preflight -# scripts/run.sh fix-launchagents -# USERNAME=$(whoami) scripts/run.sh migrate -# USERNAME=$(whoami) scripts/run.sh install -# scripts/run.sh status +USERNAME=$(whoami) PROJECT_MCP_JSON=.cursor/mcp.json bash "$SKILL_DIR/scripts/run.sh" --dry-run # preview +USERNAME=$(whoami) PROJECT_MCP_JSON=.cursor/mcp.json bash "$SKILL_DIR/scripts/run.sh" # prompts, then writes +# Or step by step (each write mode prompts; add --yes to skip the prompt): +# bash "$SKILL_DIR/scripts/run.sh" preflight +# bash "$SKILL_DIR/scripts/run.sh" fix-launchagents +# USERNAME=$(whoami) bash "$SKILL_DIR/scripts/run.sh" migrate +# USERNAME=$(whoami) bash "$SKILL_DIR/scripts/run.sh" install +# bash "$SKILL_DIR/scripts/run.sh" status ``` This will: @@ -25,7 +27,7 @@ This will: 2. Extract secrets into `~/.local/share/cursor-mcp/mcp.env` (chmod 600) 3. Rewrite `mcp.json` with `${env:VAR}` references 4. Fix `~/Library/LaunchAgents` ownership if needed (`scripts/fix-launchagents-dir.sh` — may prompt for sudo) -5. Install `~/.local/share/cursor-mcp/load-mcp-env.sh` and a LaunchAgent plist +5. Install `~/.local/share/cursor-mcp/load-mcp-env.sh` and a LaunchAgent plist that passes it the `mcp.env` and log paths 6. Bootstrap the agent and load env vars into the GUI session If you previously used `~/.cursor/mcp.env`, install moves it to the new location automatically. @@ -35,7 +37,7 @@ If you previously used `~/.cursor/mcp.env`, install moves it to the new location On some Macs `~/Library/LaunchAgents` is owned by `root` and not writable. This is expected; install fixes it automatically: ```bash -scripts/fix-launchagents-dir.sh +bash "$SKILL_DIR/scripts/fix-launchagents-dir.sh" # runs: sudo chown "$(whoami):staff" ~/Library/LaunchAgents ``` @@ -44,7 +46,7 @@ Run that script alone in Terminal if you need to approve sudo before rerunning i ## Fresh install ```bash -USERNAME=$(whoami) scripts/run.sh install +USERNAME=$(whoami) bash "$SKILL_DIR/scripts/run.sh" install ``` Edit `~/.local/share/cursor-mcp/mcp.env` with your values, then: @@ -81,7 +83,7 @@ Use `local..cursor-mcp-env`. See [launchagent-naming.md](launchag ## Ongoing workflow - **Rotate a secret:** edit `~/.local/share/cursor-mcp/mcp.env` → `launchctl kickstart -k gui/$(id -u)/local..cursor-mcp-env` → restart Cursor -- **Debug:** `tail /tmp/cursor-mcp-env.log`, then `launchctl getenv ` for any key from `mcp.env` +- **Debug:** `tail ~/.local/state/cursor-mcp/load-mcp-env.log`, then `launchctl getenv ` for any key from `mcp.env` - **Never commit** `~/.local/share/cursor-mcp/mcp.env` ## Files @@ -90,6 +92,7 @@ Use `local..cursor-mcp-env`. See [launchagent-naming.md](launchag | ---- | ------- | | `~/.local/share/cursor-mcp/mcp.env` | Secret values (chmod 600) | | `~/.local/share/cursor-mcp/load-mcp-env.sh` | Parser + `launchctl setenv` | +| `~/.local/state/cursor-mcp/load-mcp-env.log` | Loader log (key names and counts, never values) | | `~/Library/LaunchAgents/local..cursor-mcp-env.plist` | Login hook | | `~/.cursor/mcp.json` and/or `/.cursor/mcp.json` | Credential-free MCP config | diff --git a/skills/ess/mcp-hide-secrets/references/rationale.md b/skills/ess/ess-mcp-hide-secrets/references/rationale.md similarity index 91% rename from skills/ess/mcp-hide-secrets/references/rationale.md rename to skills/ess/ess-mcp-hide-secrets/references/rationale.md index 189f05a9..4fc4ad8a 100644 --- a/skills/ess/mcp-hide-secrets/references/rationale.md +++ b/skills/ess/ess-mcp-hide-secrets/references/rationale.md @@ -27,6 +27,8 @@ Use a LaunchAgent at login to run `load-mcp-env.sh`, which reads `~/.local/share Secrets and the loader live under `~/.local/share/cursor-mcp/`, not `~/.cursor/`, so Cursor’s config directory stays limited to Cursor-owned files (principally `mcp.json`). +That directory is the [XDG Base Directory](https://specifications.freedesktop.org/basedir-spec/latest/) data directory, so `XDG_DATA_HOME` moves it, and the loader’s log goes to the XDG state directory instead of world-readable `/tmp`. The spec has no place for credentials; data rather than config (`~/.config`) keeps `mcp.env` out of dotfiles repos that track `~/.config`. The LaunchAgent runs at login without the shell’s XDG variables, so install writes the resolved paths into the plist as the loader’s arguments. + ## Why not `envFile`? `envFile` is a **good, standard** pattern — the same idea as VS Code `launch.json` / `tasks.json` `envFile`, scoped to the process being started. We do not use it as the primary approach here because of **scope**, not quality. diff --git a/skills/ess/mcp-hide-secrets/scripts/check-inline-secrets.py b/skills/ess/ess-mcp-hide-secrets/scripts/check-inline-secrets.py similarity index 100% rename from skills/ess/mcp-hide-secrets/scripts/check-inline-secrets.py rename to skills/ess/ess-mcp-hide-secrets/scripts/check-inline-secrets.py diff --git a/skills/ess/mcp-hide-secrets/scripts/fix-launchagents-dir.sh b/skills/ess/ess-mcp-hide-secrets/scripts/fix-launchagents-dir.sh similarity index 74% rename from skills/ess/mcp-hide-secrets/scripts/fix-launchagents-dir.sh rename to skills/ess/ess-mcp-hide-secrets/scripts/fix-launchagents-dir.sh index ace9282d..862427bb 100755 --- a/skills/ess/mcp-hide-secrets/scripts/fix-launchagents-dir.sh +++ b/skills/ess/ess-mcp-hide-secrets/scripts/fix-launchagents-dir.sh @@ -3,11 +3,14 @@ # Required before install.sh can bootstrap the cursor-mcp-env LaunchAgent. # # Usage: -# ./fix-launchagents-dir.sh +# ./fix-launchagents-dir.sh [--dry-run] # # Common case: directory exists but is owned by root (not writable). Runs: # sudo chown "$(whoami):staff" ~/Library/LaunchAgents # +# Called by run.sh, which confirms before writing. --dry-run prints the fix +# without running it. +# # See ../SKILL.md set -euo pipefail @@ -16,7 +19,18 @@ LAUNCH_AGENTS_DIR="${LAUNCH_AGENTS_DIR:-$HOME/Library/LaunchAgents}" CURRENT_USER="$(whoami)" EXPECTED_GROUP="staff" -mkdir -p "$LAUNCH_AGENTS_DIR" +DRY_RUN=false +if [ "${1:-}" = "--dry-run" ]; then + DRY_RUN=true +fi + +if [ ! -d "$LAUNCH_AGENTS_DIR" ]; then + if [ "$DRY_RUN" = true ]; then + echo "Would create $LAUNCH_AGENTS_DIR" + exit 0 + fi + mkdir -p "$LAUNCH_AGENTS_DIR" +fi actual_owner="$(stat -f '%Su' "$LAUNCH_AGENTS_DIR")" actual_group="$(stat -f '%Sg' "$LAUNCH_AGENTS_DIR")" @@ -30,6 +44,12 @@ echo "LaunchAgents directory is not writable by $CURRENT_USER." echo " Path: $LAUNCH_AGENTS_DIR" echo " Owner: ${actual_owner}:${actual_group}" echo " Fix: sudo chown \"${CURRENT_USER}:${EXPECTED_GROUP}\" \"$LAUNCH_AGENTS_DIR\"" + +if [ "$DRY_RUN" = true ]; then + echo "Would run the fix above (sudo prompts for your password)." + exit 0 +fi + echo "" echo "Running fix (sudo will prompt for your password)..." diff --git a/skills/ess/mcp-hide-secrets/scripts/install.sh b/skills/ess/ess-mcp-hide-secrets/scripts/install.sh similarity index 54% rename from skills/ess/mcp-hide-secrets/scripts/install.sh rename to skills/ess/ess-mcp-hide-secrets/scripts/install.sh index facb5cb7..3506d9e4 100755 --- a/skills/ess/mcp-hide-secrets/scripts/install.sh +++ b/skills/ess/ess-mcp-hide-secrets/scripts/install.sh @@ -1,14 +1,20 @@ #!/bin/bash -# Install LaunchAgent + ~/.local/share/cursor-mcp/mcp.env for Cursor MCP secrets. +# Install LaunchAgent + mcp.env for Cursor MCP secrets. +# +# Files go under the XDG data directory (${XDG_DATA_HOME:-~/.local/share}/cursor-mcp/) +# and the loader logs to the XDG state directory (${XDG_STATE_HOME:-~/.local/state}/cursor-mcp/). +# Both are resolved now and written into the LaunchAgent plist. # # Usage: # USERNAME=your-username ./install.sh # fresh install from mcp.env.example # USERNAME=your-username ./install.sh --migrate-only [mcp.json] # rewrite mcp.json + mcp.env only # USERNAME=your-username ./install.sh --migrate [mcp.json] # alias for --migrate-only +# USERNAME=your-username ./install.sh --dry-run [...] # print planned changes only # ./install.sh --fix-launchagents # # --migrate / --migrate-only do NOT install the LaunchAgent; they exit after rewriting files. -# For migrate + LaunchAgent install, use scripts/run.sh (default or `migrate` / `install` modes). +# For migrate + LaunchAgent install, use scripts/run.sh (default or `migrate` / `install` modes), +# which confirms before writing. # # See ../SKILL.md @@ -20,6 +26,8 @@ source "$SCRIPT_DIR/lib.sh" MIGRATE=false MIGRATE_ONLY=false +DRY_RUN=false +FIX_LAUNCHAGENTS=false MCP_JSON="${MCP_JSON:-$GLOBAL_MCP_JSON}" while [ $# -gt 0 ]; do @@ -32,8 +40,13 @@ while [ $# -gt 0 ]; do MIGRATE_ONLY=true shift ;; + --dry-run) + DRY_RUN=true + shift + ;; --fix-launchagents) - exec "$SCRIPT_DIR/fix-launchagents-dir.sh" + FIX_LAUNCHAGENTS=true + shift ;; *) if [ -f "$1" ]; then @@ -44,6 +57,13 @@ while [ $# -gt 0 ]; do esac done +if [ "$FIX_LAUNCHAGENTS" = true ]; then + if [ "$DRY_RUN" = true ]; then + exec bash "$SCRIPT_DIR/fix-launchagents-dir.sh" --dry-run + fi + exec bash "$SCRIPT_DIR/fix-launchagents-dir.sh" +fi + if [ "$MIGRATE" = true ]; then MIGRATE_ONLY=true fi @@ -55,24 +75,29 @@ if [ -z "$USERNAME" ]; then exit 1 fi -LABEL="local.${USERNAME}.cursor-mcp-env" -PLIST_DEST="$HOME/Library/LaunchAgents/${LABEL}.plist" -MCP_ENV="$MCP_DATA_DIR/mcp.env" -LOADER_DEST="$MCP_DATA_DIR/load-mcp-env.sh" -LEGACY_MCP_ENV="$HOME/.cursor/mcp.env" LEGACY_LOADER="$HOME/.cursor/load-mcp-env.sh" -GUI_DOMAIN="gui/$(id -u)" -LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents" +# Moves inline secrets from $MCP_JSON into $MCP_ENV. Prints key names, never values. +# Writes nothing when there is nothing to change, or when $DRY_RUN is true. migrate_mcp_json() { - python3 - "$MCP_JSON" "$MCP_ENV" <<'PY' + python3 - "$MCP_JSON" "$MCP_ENV" "$DRY_RUN" <<'PY' +from __future__ import annotations + import json +import os import re +import shutil import sys +import time from pathlib import Path mcp_json_path = Path(sys.argv[1]) mcp_env_path = Path(sys.argv[2]) +dry_run = sys.argv[3] == "true" + +# Files this creates (mcp.env, the backup) must never be readable by others. +os.umask(0o077) + config = json.loads(mcp_json_path.read_text(encoding="utf-8")) servers = config.get("mcpServers", {}) @@ -110,9 +135,13 @@ def header_env_key(server_name: str, header_name: str) -> str: return f"MCP_{normalize_token(server_name)}_{normalize_token(header_name)}" +moved_keys: list[str] = [] +dropped_headers: list[str] = [] + for server_name, server in servers.items(): - if server_name.lower() == "github": - server.pop("headers", None) + if server_name.lower() == "github" and "headers" in server: + server.pop("headers") + dropped_headers.append(server_name) env_block = server.get("env") if isinstance(env_block, dict): @@ -121,6 +150,7 @@ for server_name, server in servers.items(): continue existing_env[key] = value env_block[key] = f"${{env:{key}}}" + moved_keys.append(key) headers = server.get("headers") if isinstance(headers, dict): @@ -130,28 +160,50 @@ for server_name, server in servers.items(): env_key = header_env_key(server_name, key) existing_env[env_key] = value headers[key] = f"${{env:{env_key}}}" - -if existing_env: + moved_keys.append(env_key) + +if not moved_keys and not dropped_headers: + print(f"No inline secrets in {mcp_json_path}; nothing to migrate.") + raise SystemExit(0) + +backup_path = mcp_json_path.with_name(f"{mcp_json_path.name}.bak.{time.strftime('%Y%m%d%H%M%S')}") +moved_summary = f"{len(moved_keys)} secret(s) into {mcp_env_path}: {', '.join(moved_keys)}" + +if dry_run: + print(f"Would back up {mcp_json_path} to {backup_path}") + if moved_keys: + print(f"Would move {moved_summary}") + for server_name in dropped_headers: + print(f"Would drop the headers of server '{server_name}' so it can use OAuth") + print(f"Would rewrite {mcp_json_path} with ${{env:...}} references") + raise SystemExit(0) + +shutil.copyfile(mcp_json_path, backup_path) +os.chmod(backup_path, 0o600) +print(f"Backed up {mcp_json_path} to {backup_path}") +if moved_keys: mcp_env_path.write_text( "\n".join(f"{key}={value}" for key, value in sorted(existing_env.items())) + "\n", encoding="utf-8", ) - + os.chmod(mcp_env_path, 0o600) + print(f"Moved {moved_summary}") +for server_name in dropped_headers: + print(f"Dropped the headers of server '{server_name}' so it can use OAuth") mcp_json_path.write_text( json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) +print(f"Rewrote {mcp_json_path} with ${{env:...}} references") PY - chmod 600 "$MCP_ENV" - echo "Migrated: $MCP_JSON → $MCP_ENV" } -mkdir -p "$HOME/.cursor" "$MCP_DATA_DIR" "$LAUNCH_AGENTS_DIR" +if [ "$DRY_RUN" = false ]; then + mkdir -p "$HOME/.cursor" "$MCP_DATA_DIR" "$MCP_STATE_DIR" "$LAUNCH_AGENTS_DIR" +fi -if [ -f "$LEGACY_MCP_ENV" ] && [ ! -f "$MCP_ENV" ]; then - mv "$LEGACY_MCP_ENV" "$MCP_ENV" - chmod 600 "$MCP_ENV" - echo "Moved legacy secrets: $LEGACY_MCP_ENV → $MCP_ENV" +if ! preserve_mcp_env; then + exit 1 fi if [ "$MIGRATE_ONLY" = true ]; then @@ -160,13 +212,35 @@ if [ "$MIGRATE_ONLY" = true ]; then echo " Tried: $MCP_JSON" >&2 exit 1 fi - backup_path="$MCP_JSON.bak.$(date +%Y%m%d%H%M%S)" - cp "$MCP_JSON" "$backup_path" - chmod 600 "$backup_path" migrate_mcp_json exit 0 fi +if [ "$DRY_RUN" = true ]; then + echo "Would install loader: $LOADER_DEST" + if [ -f "$LEGACY_LOADER" ] && [ "$LEGACY_LOADER" != "$LOADER_DEST" ]; then + echo "Would remove legacy loader: $LEGACY_LOADER" + fi + if [ -f "$MCP_ENV" ]; then + echo "Would keep existing $MCP_ENV" + elif [ -n "$(mcp_env_migration_source)" ]; then + echo "Would keep the copied $MCP_ENV" + else + echo "Would create $MCP_ENV from mcp.env.example unless a migration above creates it" + fi + "$SCRIPT_DIR/fix-launchagents-dir.sh" --dry-run + echo "Would write LaunchAgent plist: $PLIST_DEST" + echo " runs: $LOADER_DEST" + echo " loads: $MCP_ENV" + echo " logs to: $LOG_FILE" + if launchctl print "$GUI_DOMAIN/$LABEL" >/dev/null 2>&1; then + echo "Would reload LaunchAgent $LABEL and load $MCP_ENV into the GUI session" + else + echo "Would load LaunchAgent $LABEL and load $MCP_ENV into the GUI session" + fi + exit 0 +fi + install -m 700 "$SCRIPT_DIR/load-mcp-env.sh" "$LOADER_DEST" if [ -f "$LEGACY_LOADER" ] && [ "$LEGACY_LOADER" != "$LOADER_DEST" ]; then @@ -175,10 +249,10 @@ if [ -f "$LEGACY_LOADER" ] && [ "$LEGACY_LOADER" != "$LOADER_DEST" ]; then fi if [ ! -f "$MCP_ENV" ]; then - cp "$SCRIPT_DIR/mcp.env.example" "$MCP_ENV" + (umask 077 && cp "$SCRIPT_DIR/mcp.env.example" "$MCP_ENV") chmod 600 "$MCP_ENV" echo "Created $MCP_ENV from example — fill in secret values before restarting Cursor." -elif [ -f "$MCP_ENV" ]; then +else echo "Keeping existing $MCP_ENV" fi @@ -187,19 +261,20 @@ if ! "$SCRIPT_DIR/fix-launchagents-dir.sh"; then exit 1 fi -sed "s/local.your-username.cursor-mcp-env/${LABEL}/g" \ - "$SCRIPT_DIR/local.your-username.cursor-mcp-env.plist" >"$PLIST_DEST" +python3 "$SCRIPT_DIR/render-plist.py" "$SCRIPT_DIR/local.your-username.cursor-mcp-env.plist" \ + "$PLIST_DEST" "$LABEL" "$LOADER_DEST" "$MCP_ENV" "$LOG_FILE" if launchctl print "$GUI_DOMAIN/$LABEL" >/dev/null 2>&1; then launchctl bootout "$GUI_DOMAIN/$LABEL" || true fi launchctl bootstrap "$GUI_DOMAIN" "$PLIST_DEST" -"$LOADER_DEST" +"$LOADER_DEST" "$MCP_ENV" "$LOG_FILE" "$SCRIPT_DIR/verify.sh" echo "Installed LaunchAgent: $LABEL" echo "Loader: $LOADER_DEST" echo "Secrets: $MCP_ENV" +echo "Log: $LOG_FILE" echo "Reload after edits: launchctl kickstart -k $GUI_DOMAIN/$LABEL && restart Cursor (Cmd+Q)" diff --git a/skills/ess/ess-mcp-hide-secrets/scripts/lib.sh b/skills/ess/ess-mcp-hide-secrets/scripts/lib.sh new file mode 100755 index 00000000..45664acc --- /dev/null +++ b/skills/ess/ess-mcp-hide-secrets/scripts/lib.sh @@ -0,0 +1,77 @@ +# Shared paths for ess-mcp-hide-secrets scripts. From sibling scripts: +# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# # shellcheck source=lib.sh +# source "$SCRIPT_DIR/lib.sh" + +MCP_HIDE_SECRETS_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Prints an XDG base directory: $1 when it is an absolute path, else the default $2. +# The XDG Base Directory spec says relative values are invalid and must be ignored. +xdg_base_dir() { + case "${1:-}" in + /*) echo "$1" ;; + *) echo "$2" ;; + esac +} + +USERNAME="${USERNAME:-$(whoami)}" +LABEL="local.${USERNAME}.cursor-mcp-env" +GLOBAL_MCP_JSON="${GLOBAL_MCP_JSON:-$HOME/.cursor/mcp.json}" +PROJECT_MCP_JSON="${PROJECT_MCP_JSON:-}" +MCP_DATA_DIR="${MCP_DATA_DIR:-$(xdg_base_dir "${XDG_DATA_HOME:-}" "$HOME/.local/share")/cursor-mcp}" +MCP_STATE_DIR="${MCP_STATE_DIR:-$(xdg_base_dir "${XDG_STATE_HOME:-}" "$HOME/.local/state")/cursor-mcp}" +MCP_ENV="$MCP_DATA_DIR/mcp.env" +# The path install used before XDG_DATA_HOME, plus the pre-XDG file under ~/.cursor. +DEFAULT_MCP_ENV="$HOME/.local/share/cursor-mcp/mcp.env" +LEGACY_MCP_ENV="$HOME/.cursor/mcp.env" +LOADER_DEST="$MCP_DATA_DIR/load-mcp-env.sh" +LOG_FILE="$MCP_STATE_DIR/load-mcp-env.log" +PLIST_DEST="$HOME/Library/LaunchAgents/${LABEL}.plist" +LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents" +GUI_DOMAIN="gui/$(id -u)" +CHECK_INLINE_SECRETS="$MCP_HIDE_SECRETS_LIB_DIR/check-inline-secrets.py" + +# Prints one existing secrets file to copy when $MCP_ENV is missing. +# Prints nothing when there is nothing to copy. Returns 1 when the default and +# legacy files both exist and differ, so the caller does not guess. +mcp_env_migration_source() { + if [ -f "$MCP_ENV" ]; then + return 0 + fi + local -a candidates=() + local candidate + for candidate in "$DEFAULT_MCP_ENV" "$LEGACY_MCP_ENV"; do + if [ -f "$candidate" ] && [ "$candidate" != "$MCP_ENV" ]; then + candidates+=("$candidate") + fi + done + if [ "${#candidates[@]}" -eq 0 ]; then + return 0 + fi + if [ "${#candidates[@]}" -eq 1 ] || cmp -s "${candidates[0]}" "${candidates[1]}"; then + printf '%s\n' "${candidates[0]}" + return 0 + fi + echo "ERROR: $MCP_ENV is missing, but ${candidates[0]} and ${candidates[1]} both exist and differ." >&2 + echo "Copy the credentials you want to keep to $MCP_ENV, then rerun install." >&2 + return 1 +} + +# Copy an existing secrets file to $MCP_ENV. Does not overwrite $MCP_ENV. +preserve_mcp_env() { + local source="" + if ! source="$(mcp_env_migration_source)"; then + return 1 + fi + if [ -z "$source" ]; then + return 0 + fi + if [ "${DRY_RUN:-false}" = true ]; then + echo "Would copy secrets: $source → $MCP_ENV" + return 0 + fi + mkdir -p "$(dirname "$MCP_ENV")" + (umask 077 && cp "$source" "$MCP_ENV") + chmod 600 "$MCP_ENV" + echo "Copied secrets: $source → $MCP_ENV" +} diff --git a/skills/ess/mcp-hide-secrets/scripts/load-mcp-env.sh b/skills/ess/ess-mcp-hide-secrets/scripts/load-mcp-env.sh similarity index 57% rename from skills/ess/mcp-hide-secrets/scripts/load-mcp-env.sh rename to skills/ess/ess-mcp-hide-secrets/scripts/load-mcp-env.sh index 77ec62d8..5e89ec25 100755 --- a/skills/ess/mcp-hide-secrets/scripts/load-mcp-env.sh +++ b/skills/ess/ess-mcp-hide-secrets/scripts/load-mcp-env.sh @@ -1,18 +1,39 @@ #!/bin/bash -# Load ~/.local/share/cursor-mcp/mcp.env into the macOS GUI session via launchctl setenv. +# Load mcp.env into the macOS GUI session via launchctl setenv. # Parses line-by-line without sourcing (no shell expansion on values). +# +# Usage: +# load-mcp-env.sh [mcp.env] [log file] +# +# The LaunchAgent passes both paths, resolved at install time, because it runs at +# login without your shell's XDG variables. Without arguments, the defaults follow +# the XDG Base Directory spec like lib.sh does (this file is installed on its own, +# so it cannot source lib.sh). set -u -ENV_FILE="${MCP_ENV_FILE:-$HOME/.local/share/cursor-mcp/mcp.env}" -LOG="${MCP_ENV_LOG:-/tmp/cursor-mcp-env.log}" +case "${XDG_DATA_HOME:-}" in + /*) data_home="$XDG_DATA_HOME" ;; + *) data_home="$HOME/.local/share" ;; +esac +case "${XDG_STATE_HOME:-}" in + /*) state_home="$XDG_STATE_HOME" ;; + *) state_home="$HOME/.local/state" ;; +esac + +ENV_FILE="${1:-$data_home/cursor-mcp/mcp.env}" +LOG="${2:-$state_home/cursor-mcp/load-mcp-env.log}" +mkdir -p "$(dirname "$LOG")" log() { printf '%s [load-mcp-env] %s\n' "$(date -Iseconds)" "$*" >>"$LOG" } log_err() { - printf '%s [load-mcp-env] %s\n' "$(date -Iseconds)" "$*" >>"$LOG" >&2 + local message + message="$(printf '%s [load-mcp-env] %s\n' "$(date -Iseconds)" "$*")" + printf '%s' "$message" >>"$LOG" + printf '%s' "$message" >&2 } is_valid_key() { diff --git a/skills/ess/mcp-hide-secrets/scripts/local.your-username.cursor-mcp-env.plist b/skills/ess/ess-mcp-hide-secrets/scripts/local.your-username.cursor-mcp-env.plist similarity index 61% rename from skills/ess/mcp-hide-secrets/scripts/local.your-username.cursor-mcp-env.plist rename to skills/ess/ess-mcp-hide-secrets/scripts/local.your-username.cursor-mcp-env.plist index 142567b2..2fd98d56 100644 --- a/skills/ess/mcp-hide-secrets/scripts/local.your-username.cursor-mcp-env.plist +++ b/skills/ess/ess-mcp-hide-secrets/scripts/local.your-username.cursor-mcp-env.plist @@ -8,14 +8,11 @@ ProgramArguments /bin/bash - -c - $HOME/.local/share/cursor-mcp/load-mcp-env.sh + LOADER_PATH + MCP_ENV_PATH + LOG_PATH RunAtLoad - StandardOutPath - /tmp/cursor-mcp-env.log - StandardErrorPath - /tmp/cursor-mcp-env.log diff --git a/skills/ess/mcp-hide-secrets/scripts/mcp.env.example b/skills/ess/ess-mcp-hide-secrets/scripts/mcp.env.example similarity index 84% rename from skills/ess/mcp-hide-secrets/scripts/mcp.env.example rename to skills/ess/ess-mcp-hide-secrets/scripts/mcp.env.example index 7ec04eb8..23f8f252 100644 --- a/skills/ess/mcp-hide-secrets/scripts/mcp.env.example +++ b/skills/ess/ess-mcp-hide-secrets/scripts/mcp.env.example @@ -1,4 +1,4 @@ -# Installed to ~/.local/share/cursor-mcp/mcp.env (chmod 600). +# Installed to ${XDG_DATA_HOME:-~/.local/share}/cursor-mcp/mcp.env (chmod 600). # One KEY=VALUE per line. No quotes, no export, no shell expansion. # Comments and blank lines are ignored. # diff --git a/skills/ess/mcp-hide-secrets/scripts/preflight.sh b/skills/ess/ess-mcp-hide-secrets/scripts/preflight.sh similarity index 92% rename from skills/ess/mcp-hide-secrets/scripts/preflight.sh rename to skills/ess/ess-mcp-hide-secrets/scripts/preflight.sh index 07614a4f..42cda087 100755 --- a/skills/ess/mcp-hide-secrets/scripts/preflight.sh +++ b/skills/ess/ess-mcp-hide-secrets/scripts/preflight.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Read-only preflight for mcp-hide-secrets. Never reads or prints secret values. +# Read-only preflight for ess-mcp-hide-secrets. Never reads or prints secret values. # # Usage: # ./preflight.sh @@ -15,7 +15,7 @@ source "$SCRIPT_DIR/lib.sh" issues=0 -echo "== mcp-hide-secrets preflight ==" +echo "== ess-mcp-hide-secrets preflight ==" if [ "$(uname -s)" != "Darwin" ]; then echo "FAIL: macOS required (got $(uname -s))" diff --git a/skills/ess/ess-mcp-hide-secrets/scripts/render-plist.py b/skills/ess/ess-mcp-hide-secrets/scripts/render-plist.py new file mode 100755 index 00000000..13426cb9 --- /dev/null +++ b/skills/ess/ess-mcp-hide-secrets/scripts/render-plist.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Render the LaunchAgent plist from its template with absolute, resolved paths. + +Usage: render-plist.py